PR 2008 fixes issue #1915 by requiring every saleable visit pack to carry a finite, positive numeric(10,2) visit count. API validation rejects invalid writes before provider catalog mutation, migration 0194 refuses ambiguous legacy data before adding a database constraint, and one shared issuance guard snapshots the balance across checkout, staff membership creation, POS, and payment webhooks. Stripe and Square webhooks compensate already-captured legacy sessions instead of retrying forever with no entitlement or refund.
fix/1915-visit-pack-count at 54f48c66d, pushed after correcting every CI fixture that intentionally creates a valid visit pack
Generated
Reviewer brief
Outcome and scope
A paid visit pack can no longer be minted with an unusable balance
Create and update requests reject omitted, null, non-positive, over-precision, and out-of-range visit counts. The database enforces the same range while retaining the reserved staff-comp template exception. Every membership issuance path uses the shared guard. New checkout sessions stop before provider calls, while captured Stripe and Square sessions enter idempotent refund compensation. Focused unit tests, the complete API unit and PostgreSQL suites, all 39 simulation files, migration-chain checks, and API and simulation type checks passed.
A missing historical visit count cannot be derived safely from price or membership history. Migration 0194 checks active and inactive visit packs, excluding only the reserved staff-comp template, and raises repair guidance before installing the constraint. Operators must deactivate and assign the intended balance before retrying rather than letting the migration invent paid credits.
Claim map
Evidence coverage
Each material changed behavior or state maps to direct evidence or an explicit gap.
Behavior or claim
Evidence
Status
A visit pack requires a finite, positive numeric(10,2) count on create and update
Route validation, schema constraint, unit boundary tests, and PostgreSQL API tests
0194 preflight plus a real PostgreSQL migration test covering active, inactive, reserved, zero, negative, null, infinite, and valid rows
Covered
Every membership issuance path snapshots the plan balance through one guard
Shared helper use in checkout, staff issuance, plan reassignment, POS, Square completion, and Square and Stripe webhooks, with focused route tests
Covered
A new invalid checkout is rejected before provider mutation
Checkout session and POS preflight tests assert 409 and zero provider calls
Covered
Already-captured invalid Stripe and Square sessions are compensated
Webhook tests assert idempotent provider refund calls, no membership insert, and refunded payment state
Covered
Live Stripe and Square accounts accepted the compensation calls
Gap: provider network traffic was not executed. Existing provider adapters were exercised through deterministic mocks.
Gap
Observed change
Review evidence
Evidence is grouped by the reviewer question each item addresses.
Invariant boundary
Table
Invalid visit-pack states now fail at the earliest authoritative boundary
The same numeric contract is enforced before remote provider mutation, during issuance, and in PostgreSQL.
Input or state
Before
After
Create visit pack without visitCount
Plan could be stored and sold with null credits
HTTP 400 before catalog or database mutation
Create visit pack with 100000000
Provider catalog could be written before PostgreSQL rejected numeric(10,2)
HTTP 400 before provider mutation
Issue membership from legacy invalid plan
Active membership could carry null visitsRemaining and visitsTotal
Shared guard refuses issuance
Captured legacy Stripe or Square session
Webhook retried without membership or refund
Idempotent compensation refunds and marks payment refunded
Deploy with ambiguous legacy rows
No database invariant
Migration fails closed with repair instructions
Database migration
Code
0194 refuses ambiguous data before installing the constraint
The preflight checks all non-reserved visit packs and names the required operator action. The final check constraint permits the staff-comp template and requires 0.01 through 99999999.99 for every other visit pack.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "membership_plans"
WHERE "name" <> '__staff_comp_credits__'
AND "plan_type" = 'visit_pack'
AND ("visit_count" IS NULL OR "visit_count" NOT BETWEEN 0.01 AND 99999999.99)
) THEN
RAISE EXCEPTION 'membership_plans contains visit packs without a finite positive visit_count; deactivate and repair each plan before retrying migration 0194';
END IF;
END
$$;
--> statement-breakpoint
ALTER TABLE "membership_plans" ADD CONSTRAINT "membership_plans_visit_pack_count_positive" CHECK ("membership_plans"."name" = '__staff_comp_credits__' OR "membership_plans"."plan_type" <> 'visit_pack' OR ("membership_plans"."visit_count" IS NOT NULL AND "membership_plans"."visit_count" BETWEEN 0.01 AND 99999999.99));
Shared issuance guard
Code
One helper defines the balance snapshot for every issuer
Non-visit plans carry no visit balance. A visit pack must satisfy format, positivity, and numeric precision range before its stored count can become both remaining and total credits.
Shared issuance contract at code head 5039094c0.After
export function membershipPlanVisitBalance(
plan: MembershipPlanBalanceSource,
): string | null {
if (plan.planType !== "visit_pack") return null;
if (
plan.visitCount == null ||
!isValidCreditAmount(plan.visitCount) ||
Number(plan.visitCount) <= 0 ||
Number(plan.visitCount) > 99999999.99
) {
throw new Error("Visit-pack plan has no positive visit count");
}
return plan.visitCount;
}
Pre-capture checkout guard
Code
Invalid plans stop before any provider session is created
The selected tenant-scoped active plan is checked immediately after lookup. Stripe, Square, and PayPal branching occurs only after this gate.
Checkout preflight at code head 5039094c0.After
packages/api/src/routes/checkout.ts:962-973
const selectedPlan = plan[0];
try {
membershipPlanVisitBalance(selectedPlan);
} catch {
return c.json(
{
error:
"This visit pack is missing a positive visit count. Choose another plan.",
},
409,
);
}
Stripe compensation
Code
Paid legacy sessions refund with a stable idempotency key
The webhook recognizes invalid legacy plan data before claiming the payment transaction. It enters the same provider compensation machinery used for duplicate memberships and uses a payment-stable invalid-plan refund key.
Stripe invalid-plan branch and refund identity at code head 5039094c0.After
Captured Square payments refund from stored authoritative amounts
The refund uses the payment row's amount and currency, refuses a mismatched delivered amount with an operator exception, and marks the payment refunded only after the provider call succeeds.
Square refund call and invalid-plan branch at code head 5039094c0.After
await refundSquarePayment(squareClient, {
paymentId: squarePaymentId,
amount: formattedAmount,
currency,
reason:
reason === "invalid_plan"
? "Visit-pack plan has no finite positive visit count"
: "Customer already has an active membership",
idempotencyKey: `refund-${payment.id}`,
});
await db
.update(payments)
.set({ status: "refunded", squarePaymentId, updatedAt: new Date() })
.where(eq(payments.id, payment.id));
let visitBalance: string | null;
try {
visitBalance = membershipPlanVisitBalance(plan);
} catch {
await refundRejectedMembership("invalid_plan");
return;
}
Issuance coverage
Table
All membership minting paths share the same visit-balance decision
The helper is called where the plan is already tenant-scoped and before each membership insert. Focused tests cover the changed observable boundary in each major channel.
Channel
Guarded path
Observable result
Hosted checkout
checkout.ts
409 before provider session; valid completion snapshots both balances
Staff issuance and plan reassignment
memberships.ts
Invalid plan returns 409; valid pack snapshots both balances
Point of sale
pos-orders.ts
Invalid pack fails before transaction and valid sale mints exact balance
Square direct completion
checkout.ts
Invalid pack fails before card capture
Square webhook
square-webhook.ts
Captured invalid pack is refunded and no membership is inserted
Stripe webhook
stripe-webhook.ts
Paid invalid pack is refunded and no membership is inserted
Behavioral verification
Table
Focused tests exercise API, issuance, provider, and migration boundaries
Unit suites ran independently to avoid Bun module-mock collisions. PostgreSQL suites used an isolated local PostgreSQL 16 database migrated through 0194.
Suite
Observed
Membership plan validation and shared balance
32 pass, 0 fail
Checkout and direct Square or PayPal completion
84 pass, 0 fail
Stripe webhook
73 pass, 0 fail
Square webhook
56 pass, 0 fail
POS order issuance
45 pass, 0 fail
PostgreSQL API and migration regressions
3 pass, 0 fail
Confidence
Validation
Pass
Focused API suites
290 tests passed with 0 failures and 1,142 assertions across membership plan, shared balance, checkout, POS, Stripe webhook, and Square webhook suites.
Pass
PostgreSQL invariant and migration suites
Three tests passed with 0 failures against an isolated PostgreSQL 16 database: create and update API behavior plus migration preflight, reserved-template compatibility, valid-row preservation, and post-migration write rejection.
Pass
Fresh migration and schema convergence
A fresh database migrated through 0194, a separate comparison database received drizzle-kit push, and assert-migration-chain reported 194 migrations applied with matching 96-table and 56-enum schemas.
Pass
API typecheck and formatting
bun run --filter @sauna-crm/api typecheck exited 0. Biome formatted the ten changed TypeScript source and test files without remaining diagnostics.
Pass
Independent red-team review
The reviewer found missing checkout preflight and captured-payment compensation plus a missing numeric upper bound. The branch now includes all three corrections and focused regressions that pass.
Pass
Complete API unit and PostgreSQL suites
The repository API unit command and complete database integration command both exited 0 after the migration regression moved into the database suite and five valid visit-pack fixtures gained explicit balances.
Pass
Complete simulation suite
All 39 scenario files passed in 29.8 seconds against a separately prepared local simulation database. Worker simulation coverage remained 25 of 25 registered jobs.
Pass
API and simulation type checks
bun run --filter @sauna-crm/api typecheck and bun run --filter @sauna-crm/simulation typecheck both exited 0 after the fixture corrections.
Warn
Exact-head CI
Run 32920831018 failed because five existing valid-plan fixtures omitted visitCount and the new migration test initially remained in the unit lane. Commit 54f48c66d corrected those exact failures; the complete local unit, database, and simulation lanes now pass. A new exact-head CI run is pending.
Limits
Known issues, risks, and gaps
Deployment fails closed on ambiguous data; external provider execution remains the principal unobserved boundary
Legacy invalid rows require operator repair before migration Migration 0194 checks active and inactive rows because either can later become saleable. A matching row stops deployment until an operator deactivates it and assigns the intended positive count. This is deliberate data-integrity protection, not an automatic backfill.
Provider compensation was verified through adapters, not live accounts Stripe and Square webhook regressions assert provider identifiers, amounts, currency, reasons, idempotency keys, local payment state, and absence of membership inserts. No real provider charge was created. Provider network behavior therefore remains covered by the existing adapters and CI rather than live traffic in this report.
A provider refund failure intentionally leaves the event retryable Stripe and Square compensation rethrow provider failures so webhook redelivery retries the same idempotent refund. Until a later delivery succeeds, the payment remains pending and the membership remains absent. This preserves a recoverable state rather than falsely acknowledging compensation.
Generated Drizzle metadata dominates the line count The new 0194 snapshot is generated schema metadata. The behavioral change is concentrated in the migration, schema check, shared helper, guarded issuance call sites, and focused tests.