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.
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 claim
Evidence
Status
Old API replicas remain compatible with the schema change
Migration excerpt and rollout compatibility table
Covered
New captured paid-group refund decisions stamp a durable marker under payment-row then slot-lock order
Focused transaction excerpt, route unit suites, and PostgreSQL handoff test
Covered
An ambiguous transaction error does not authorize a refund until committed group rows are replayed
Focused unit coverage in Square, PayPal, Stripe, and group idempotency suites
Covered
No delivery can insert a paid group after compensation starts
Intentionally 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 state
Refund behavior
Insertion behavior
#1337 status
Before PR 1913
No durable pre-refund marker
Ignores marker
Open
PR 1913 rolling out
New replicas stamp marker, old replicas do not
All replicas preserve existing insertion behavior
Open
PR 1913 fully deployed
Every live refunder stamps marker
Still ignores marker until stage 2
Open, ready for reader deployment
Stage 2 fully deployed
Every refunder stamps marker
Application and database refuse later group seat writes
Closed 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
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.
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.
Path
Captured identity
Compensation after marker
Square complete
Payment id plus Square payment reference
Square refund
PayPal complete
Payment id plus PayPal capture reference when available
PayPal refund or operator exception
Stripe webhook
Payment id plus PaymentIntent reference
Stripe refund
Gift card
No external captured payment
Debit 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.