Change evidence report

Visit-pack balance invariant: reviewer evidence pack

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.

Range
88e0ee2520bb7d9e4a714426e7319c50ae2c2962…54f48c66ddef49d004ad5c5c2aa253398f02b669
Source state
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.

Migration 0194 deliberately refuses ambiguous legacy rows

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 claimEvidenceStatus
A visit pack requires a finite, positive numeric(10,2) count on create and updateRoute validation, schema constraint, unit boundary tests, and PostgreSQL API testsCovered
Legacy invalid rows cannot survive migration silently0194 preflight plus a real PostgreSQL migration test covering active, inactive, reserved, zero, negative, null, infinite, and valid rowsCovered
Every membership issuance path snapshots the plan balance through one guardShared helper use in checkout, staff issuance, plan reassignment, POS, Square completion, and Square and Stripe webhooks, with focused route testsCovered
A new invalid checkout is rejected before provider mutationCheckout session and POS preflight tests assert 409 and zero provider callsCovered
Already-captured invalid Stripe and Square sessions are compensatedWebhook tests assert idempotent provider refund calls, no membership insert, and refunded payment stateCovered
Live Stripe and Square accounts accepted the compensation callsGap: 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 stateBeforeAfter
Create visit pack without visitCountPlan could be stored and sold with null creditsHTTP 400 before catalog or database mutation
Create visit pack with 100000000Provider catalog could be written before PostgreSQL rejected numeric(10,2)HTTP 400 before provider mutation
Issue membership from legacy invalid planActive membership could carry null visitsRemaining and visitsTotalShared guard refuses issuance
Captured legacy Stripe or Square sessionWebhook retried without membership or refundIdempotent compensation refunds and marks payment refunded
Deploy with ambiguous legacy rowsNo database invariantMigration 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.

Migration 0194 at code head 5039094c0.After
packages/api/drizzle/0194_green_doctor_spectrum.sql:5-19
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
packages/api/src/lib/membership-plan-balance.ts:16-28
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
packages/api/src/routes/stripe-webhook.ts:1726-1731,1811-1817
const refundIdempotencyKey =
  reason === "invalid_plan"
    ? `invalid-plan-refund-${paymentRow.id}`
    : `duplicate-refund-${paymentRow.id}`;

let visitBalance: string | null;
try {
  visitBalance = membershipPlanVisitBalance(plan);
} catch {
  await refundRejectedMembership("invalid_plan");
  return;
}

Square compensation

Code

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
packages/api/src/routes/square-webhook.ts:563-580,596-602
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.

ChannelGuarded pathObservable result
Hosted checkoutcheckout.ts409 before provider session; valid completion snapshots both balances
Staff issuance and plan reassignmentmemberships.tsInvalid plan returns 409; valid pack snapshots both balances
Point of salepos-orders.tsInvalid pack fails before transaction and valid sale mints exact balance
Square direct completioncheckout.tsInvalid pack fails before card capture
Square webhooksquare-webhook.tsCaptured invalid pack is refunded and no membership is inserted
Stripe webhookstripe-webhook.tsPaid 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.

SuiteObserved
Membership plan validation and shared balance32 pass, 0 fail
Checkout and direct Square or PayPal completion84 pass, 0 fail
Stripe webhook73 pass, 0 fail
Square webhook56 pass, 0 fail
POS order issuance45 pass, 0 fail
PostgreSQL API and migration regressions3 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.