Change evidence report

Paid-group refund handoff stage 1: reviewer evidence pack

PR 1913 is the first of two rolling-deploy-safe changes for issue #1337. It adds a nullable payment handoff marker, orders captured paid-group decisions by payment row then booking slot, stamps the marker before returning any refundable decision, and recovers ambiguous commits by replaying durable group rows. It deliberately does not reject insertion from the marker yet. That reader and its database guard deploy only after this release drains every old refunder.

Range
c10814a2b71177998666a1af0d285c4c50ad55f7…4016f5584099a15b3e172d08629792cca7e738c5
Source state
fix/1337-group-close-guard at 4016f5584, pushed as draft PR 1913 before evidence source was added
Generated

Reviewer brief

Outcome and scope

Every new paid-group refunder writes a durable handoff without breaking old replicas

The additive migration accepts old and new API versions concurrently. Captured group decisions now acquire the payment row before the existing slot lock, replay any committed seats under both locks, and stamp bookingRefundStartedAt before returning a refund-authorizing outcome. Focused unit and PostgreSQL tests pass, two fresh schema constructions converge, and independent correctness and security reviews found no stage-1 regression. The original reverse-order race remains intentionally open until stage 2.

This PR is a deployment prerequisite, not the complete #1337 fix

Stage 1 writes the marker but intentionally ignores it when inserting. Enabling marker refusal in this PR would let a new writer race an old refunder that cannot stamp the marker. Merge and fully deploy PR 1913 first. Only after the release reports success and old API replicas have drained may stage 2 add the application refusal and INSERT/UPDATE database guard. The issue remains open throughout stage 1.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
Old API replicas remain compatible with the schema changeMigration excerpt and rollout compatibility tableCovered
New captured paid-group refund decisions stamp a durable marker under payment-row then slot-lock orderFocused transaction excerpt, route unit suites, and PostgreSQL handoff testCovered
An ambiguous transaction error does not authorize a refund until committed group rows are replayedFocused unit coverage in Square, PayPal, Stripe, and group idempotency suitesCovered
No delivery can insert a paid group after compensation startsIntentionally deferred. Stage 1 neither reads the marker nor installs the database trigger. Stage 2 closes this gap after replica drain.Gap

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Rolling deployment contract

Table

Writer and reader ship in separate releases

The split prevents either mixed-version pairing from creating a new compatibility hazard. Stage 1 is safe with old replicas because the column is nullable and insertion behavior is unchanged.

Deployment stateRefund behaviorInsertion behavior#1337 status
Before PR 1913No durable pre-refund markerIgnores markerOpen
PR 1913 rolling outNew replicas stamp marker, old replicas do notAll replicas preserve existing insertion behaviorOpen
PR 1913 fully deployedEvery live refunder stamps markerStill ignores marker until stage 2Open, ready for reader deployment
Stage 2 fully deployedEvery refunder stamps markerApplication and database refuse later group seat writesClosed after verification

Database migration

Code

0192 adds one nullable, no-default handoff column

Existing rows remain null and old replicas can continue reading and writing the payments table during the first rolling deployment.

Complete stage-1 migration at head 4016f5584.After
packages/api/drizzle/0192_booking_refund_handoff.sql:1
ALTER TABLE "payments" ADD COLUMN "booking_refund_started_at" timestamp with time zone;

Paid-group transaction

Code

Captured decisions lock payment before slot and stamp before refund authority escapes

The payment row is locked first. The existing per-slot advisory lock follows. Once no committed group seat is found, every refundable refusal calls startCompensation inside the same transaction before returning to provider-specific refund code.

Lock and handoff core at head 4016f5584.After
packages/api/src/routes/widget-book-checkout.ts:5141-5198
const lockedPayment = capturedPayment
  ? (await tx.select({
      status: payments.status,
      bookingRefundStartedAt: payments.bookingRefundStartedAt,
    }).from(payments).where(/* payment id and customer */).limit(1).for("update"))[0]
  : null;

await tx.execute(
  sql`SELECT pg_advisory_xact_lock(hashtextextended(${scheduleId} || '|' || ${date}, 0))`,
);

const startCompensation = async () => {
  if (!capturedPayment || lockedPayment?.bookingRefundStartedAt) return;
  const now = new Date();
  await tx.update(payments)
    .set({ bookingRefundStartedAt: now, updatedAt: now })
    .where(/* same payment id and customer */);
};

const seatedRows = await readGroupRows(tx, bookingGroupId);
if (groupStillHoldsASeat(seatedRows)) {
  return { kind: "already_seated", rows: seatedRows };
}
if (!insertIfUnseated) {
  await startCompensation();
  return { kind: "compensation_started" };
}

Provider coverage

Table

All externally captured group paths enter the same handoff

Gift-card checkout is excluded because its balance debit and seat insertion already share one database transaction. The three external providers pass a capturedPayment identity into the shared group transaction.

PathCaptured identityCompensation after marker
Square completePayment id plus Square payment referenceSquare refund
PayPal completePayment id plus PayPal capture reference when availablePayPal refund or operator exception
Stripe webhookPayment id plus PaymentIntent referenceStripe refund
Gift cardNo external captured paymentDebit and seat insert roll back together

Confidence

Validation

Pass

Workspace typechecks

bun run typecheck completed with every workspace exiting 0.

Pass

Provider and idempotency unit suites

101 tests passed with 0 failures and 365 assertions across widget-book-checkout-refund, stripe-webhook, and widget-book-checkout-group-idempotency.

Pass

PostgreSQL seat-write and handoff suite

10 tests passed with 0 failures and 28 assertions against local PostgreSQL, including cutoff refusal, durable marker persistence, replay, ban, and ordinary group insertion.

Pass

Fresh migration chain and schema convergence

One empty database migrated through 0192 and a separate empty database received drizzle-kit push. assert-migration-chain reported 192 migrations applied and matching schemas.

Pass

Independent staged-rollout reviews

A security reviewer and an independent correctness reviewer inspected the stage-1 diff. Both found no new financial-integrity, lock-order, schema, migration, or mixed-version compatibility regression after comments were corrected to state the deferred invariant.

Warn

Exact-head CI

Pending on draft PR 1913. Local focused proof passed before this evidence source was authored.

Limits

Known issues, risks, and gaps

The incomplete invariant and deployment ordering are intentional and review-visible

Issue #1337 remains open after stage 1 A later delivery can still insert a paid group after another delivery starts compensation because stage 1 intentionally does not read bookingRefundStartedAt. This is the pre-existing defect, not a claimed fix. Stage 2 adds both application refusal and a database INSERT/UPDATE guard after every live refunder writes the marker.

Stage 2 must wait for full stage-1 deployment Merging the marker reader before old replicas drain would recreate the mixed-version hole: an old refunder can move money without writing the marker while a new writer accepts the unmarked payment. Release completion is the deployment gate.

Marker persistence favors reconciliation over automatic retry If an external provider refund is ambiguous or fails, the marker remains. This prevents a later automated path from treating the capture as safe to seat once stage 2 lands. Existing provider refund-attempt and operator-exception channels retain the evidence needed for reconciliation.

Solo bookings are outside this group-specific handoff The new marker transaction and the planned trigger apply to group payments carrying friend customer ids. Solo booking compensation remains governed by its existing flow and is not presented as fixed by #1337.