Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ebca5d0
Stage paid orders at quantity zero before payment
claude Jul 13, 2026
37492fb
Treat a gone checkout stage as an impossible state, not a recovery
claude Jul 13, 2026
52937b8
Document the staged-checkout money model in plan.md
claude Jul 13, 2026
7a60e5c
Refuse an unbookable staged checkout before the payment provider
claude Jul 13, 2026
5818fbb
Keep a pending checkout's rows when its listing is deleted
claude Jul 13, 2026
0f95fb1
Record the staged money-model decision and the no-seat-holding policy
claude Jul 13, 2026
d73c4f0
Book a staged checkout as a guarded compare-and-set
claude Jul 13, 2026
756503b
Record the deleted-listing booking-view follow-up in TODO.md
claude Jul 13, 2026
21ed057
Record staged-refund money before stamping or resolving
claude Jul 13, 2026
99a13da
Block no-quantity while an attendee holds unreturned conflict cash
claude Jul 13, 2026
6f49011
Block delete and merge while a record holds conflict cash
claude Jul 13, 2026
26ccc4e
Stamp the payment reference when the ledger heals a wedged stage
claude Jul 13, 2026
fa8b15d
Record the paid-stage-prune edge as a deliberate accepted decision
claude Jul 13, 2026
93ad649
Bring plan.md up to date with the shipped review work
claude Jul 13, 2026
880ac8e
Surface the mid-payment state and hide dead controls on pending records
claude Jul 13, 2026
4c8aeff
Gate note ledger links by role and keep deleted-listing bookings visible
claude Jul 13, 2026
e2cb0d1
Reconcile stray Stripe webhook endpoints on setup
claude Jul 13, 2026
0fab1da
Fold every attendee purge onto one shared mechanism
claude Jul 13, 2026
03c0564
Make the slug-uniqueness test deterministic
claude Jul 13, 2026
eea92fe
Record the two open Codex findings (ledger-tab gate, deleted-line edi…
claude Jul 14, 2026
ecb5f81
Lock the attendee editor for bookings whose listing was deleted
claude Jul 14, 2026
db71893
Block manual ledger writes and listing deletes that would corrupt hel…
claude Jul 14, 2026
0ffd541
Harden three staged-checkout edges: refund retry, over-broad guard, d…
claude Jul 14, 2026
2786a99
Record the deleted-listing / held-cash / refund-retry Codex round in …
claude Jul 14, 2026
cff1562
Add review
stefan-burke Jul 14, 2026
0f80ae5
Fix branch review findings
stefan-burke Jul 14, 2026
31a4910
Plan staged-checkout PR split
stefan-burke Jul 14, 2026
1a5eeab
Prepare the next staged-checkout split
stefan-burke Jul 14, 2026
09d788c
Merge current main into staged bookings
stefan-burke Jul 15, 2026
57f5bd9
Fix staged restore and cleanup gates
stefan-burke Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ as-is and skips the download, so `deno task test`, `deno task test:files`, and
- **Malleable software**: Prefer being up front with operators about the underlying data structure over hiding it. Where it's safe, expose stored records directly and give the operator a page to view and edit them — including aggregated/derived numbers — rather than treating the DB as a black box. The per-contact record editor at `/admin/history/:hmac` (raw booking/message counts plus the private note, keyed by the contact's HMAC) is the reference example. Repairing data should be a first-class operator action, not a manual DB surgery.
- **Never render a dead or forbidden link**: Don't emit a link the viewer can't follow — one whose target would 404, or whose page the current user's admin level can't open. A rendered link is a promise that it works, so gate it on the same condition the target enforces; when that condition fails, show plain text or an indicator in its place rather than a link that breaks on click. The no-quantity attendee's ticket cell is the reference: a quantity-0-only attendee has no live `/t` page (it 404s), so admin views render a "No quantity" indicator instead of the `/t` link. This holds for permission-gated links too: an action a role can't reach must not be linked for that role. Mind the blind spot — a link to a restricted page still works when the page is viewed (or tested) as a high-privilege user, so the dead link the lower-privilege roles see goes unnoticed. Gate the link on the same permission the target enforces, and when testing visibility, render the page as each role rather than only the most-privileged one.
- **Operator decides genuine conflicts — a required choice, never a silent default**: When an action hits a conflict the system cannot unambiguously resolve (e.g. an attendee merge where both records booked the same listing, or where each side carries a real payment), do NOT auto-pick a resolution and quietly proceed. Surface the conflict and make the operator choose explicitly via a **required** field — the request fails closed until they decide. Silently moving money, voiding a leg, or keeping one side by default hides a real decision behind a guess; an explicit operator choice keeps the irreversible call — especially anything that touches the money ledger — with the human who can see the context.
- **Never hold a seat**: A checkout NEVER reserves capacity. A staged booking is written at **quantity 0** (`stageCheckout`), so it claims no seat while the customer pays; the real quantity — and the authoritative capacity claim — lands only at activation, after the payment confirms. First payment to land wins; a second buyer who pays for the genuinely-last seat is refunded by the terminal refund path (rare, and cheaper than the alternative). We do **not** reserve because holding seats invites botting and ghost-checkout lockouts on exactly the scarce listings where it hurts most. The availability preflight (`checkBatchAvailability` in `createStagedCheckout`) refuses a checkout that already can't fit, but it *holds* nothing. This is policy: never add a "reserve this seat for N minutes" mechanism.
- **Select only needed columns**: Avoid `SELECT *` and broad "load every row" helpers — query the specific columns a caller actually uses. See [Database Queries](#database-queries).
- **SQL table aliases**: Alias tables with the full singular word using `AS`, not a single letter — write `FROM listings AS listing`, never `FROM listings e` (the `e` is a leftover from when listings were called "events"). When one query references the same table more than once (e.g. correlated subqueries that compare a row against its group), give each occurrence a descriptive word alias — `listing` for the row being checked, `groupListing` for sibling rows in its group.
- **Annotate return types on exported functions, and keep types easy to compile**: Give every exported/public function an explicit return type instead of leaning on inference. A named annotation is more compact for the checker to record than a re-inferred anonymous type, and it fails loudly at the definition when the body drifts from the contract rather than leaking a surprising shape to callers. This is the [TypeScript performance guidance](https://github.com/microsoft/TypeScript/wiki/Performance) applied to our checker (`deno check` is the same compiler underneath): prefer an `interface`/base type that others extend over a large `type X = A & B & C` intersection or a wide bare union (comparing many members is quadratic), and give a complex conditional type its own name so the compiler caches it instead of re-deriving it at every use. A small two-way `A & B` merge, or a `v.variant`/discriminated union built from the schema-first patterns above, is already the right shape — this is about not hand-rolling sprawling anonymous ones. (The wiki's `tsconfig`/project-reference/tracing advice does not apply: we type-check with `deno check`, not `tsc`.)
Expand Down
303 changes: 303 additions & 0 deletions PR_SPLIT_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
# Staged-checkout branch split plan

## Goal

The staged-checkout branch is too large to review or merge safely as one pull
request. Against `origin/main` at `32a47a03`, it changes 248 files with about
11,700 added lines. Its existing commits are not useful pull request boundaries:
the first feature commit changes 135 files, and the final review commit mixes
many separate fixes across another 113 files.

Rebuild the work as small semantic changes from current `origin/main`. Do not
cherry-pick the broad branch commits. Each job below has its own persistent git
worktree, branch, agent, and pull request.

## Current state

The monolithic branch remains a committed reference. Do not merge it into
`main`: the prerequisite work was rebuilt independently, and a trial merge
against current `main` produces conflicts across about thirty files. New work
must start from current `main` and port only the behavior in its named scope.

Five prerequisite pull requests are merged:

- #1821: stage-neutral attendee purge unification.
- #1822: atomic placeholder refund ledger.
- #1823: QR checkout error propagation.
- #1824: Stripe refund status correctness.
- #1826: owner-safe links in attendee notes.

#1827, Stripe webhook setup hardening, is the final prerequisite. At the latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the malformed PR reference.

Line 30 starts with #1827, triggering MD018. Prefix it with PR so it remains prose rather than heading-like Markdown.

Proposed fix
-#1827, Stripe webhook setup hardening, is the final prerequisite.
+PR `#1827`, Stripe webhook setup hardening, is the final prerequisite.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#1827, Stripe webhook setup hardening, is the final prerequisite. At the latest
PR `#1827`, Stripe webhook setup hardening, is the final prerequisite. At the latest
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 30-30: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PR_SPLIT_PLAN.md` at line 30, Update the line beginning with “#1827” in
PR_SPLIT_PLAN.md to prefix the reference with “PR”, preserving the surrounding
prose and meaning.

Source: Linters/SAST tools

audit, local `main` and `origin/main` point at `31417124`; start the next branch
only after #1827 has merged and `main` has been fast-forwarded again.

The agents must preserve newer work already on main, especially:

- Use `legMatches` from `src/shared/ledger/legs.ts`; do not restore a parallel
transfer-leg matcher from the feature branch.
- Use `parseDateMs` from `src/shared/dates.ts` in any moved backup code; do not
reintroduce direct `Date.parse` calls.
- Run focused tests while developing. Do not manually run mutation testing or
repeated full precommit runs. The commit hook and CI own the full gate.

## Merge-first pull requests

### 1. Stripe refund status correctness

**Branch:** `split/stripe-refund-status`

**Worktree:** `.pi-worktrees/stripe-refund-status`

Only report a Stripe refund as complete when Stripe returns
`status === "succeeded"`. Pending, action-required, failed, and cancelled
refunds remain unresolved.

Primary scope:

- `src/shared/stripe-provider.ts`
- `test/lib/stripe/provider.test.ts`
- Existing refund mocks that currently return only an ID and must return a
realistic successful status.

Do not bring in checkout-stage state, checkout expiry handling, webhook event
reconciliation, or staged-refund retry code.

Acceptance:

- Direct tests cover every Stripe refund status.
- Existing refund-flow tests still model successful refunds accurately.
- The PR contains no checkout-stage schema or runtime changes.

### 2. Atomic placeholder refund ledger

**Branch:** `split/placeholder-refund-ledger`

**Worktree:** `.pi-worktrees/placeholder-refund-ledger`

Post a placeholder's received payment and completed cash refund as one atomic
transfer-group write. A conflict in the refund leg must roll back the payment
leg as well.

Primary scope:

- `src/shared/refund-ledger.ts`
- `test/shared/refund-ledger-placeholder.test.ts`
- A focused split of existing refund-ledger tests if needed to keep files small.

Use the current main branch's shared ledger matching helpers. Do not bring in
checkout stages, held-cash admin guards, transfer batch primitives needed only
by staged activation, or refunding-state transitions.

Acceptance:

- A refund-reference collision proves neither transfer group is committed.
- Payment-only recording remains correct when no provider refund completed.
- Existing attendee refund behavior is unchanged.

### 3. Stage-neutral attendee purge unification

**Branch:** `split/attendee-purge`

**Worktree:** `.pi-worktrees/attendee-purge`

Replace the separate single-attendee and orphan dependent-row deletion lists
with one shared statement builder for tables that already exist on main.

Primary scope:

- `src/shared/db/attendees/delete.ts`
- `src/shared/db/orphan-attendees.ts`
- Existing attendee-delete and orphan-purge tests.

The shared main-ready dependent set is `processed_payments`,
`attendee_answers`, `listing_attendees`, `system_notes`, and `service_costs`.

Do not add `checkout_stages`, stage-last ordering, stale payment-claim cleanup,
or checkout cancellation/pruning. Those extend this mechanism later.

Acceptance:

- Single deletion and orphan deletion use the same dependent-row mechanism.
- Service costs and every existing dependent table are still removed.
- The change is a real deduplication and preferably a net deletion.

### 4. Stripe webhook setup hardening

**Branch:** `split/stripe-webhook-setup`

**Worktree:** `.pi-worktrees/stripe-webhook-setup`

During Stripe setup, delete both the recorded endpoint and stray endpoints with
the exact same site webhook URL. Save the new endpoint ID and signing secret
atomically. Use one shared payment-webhook URL helper.

Primary scope:

- `src/shared/stripe.ts`
- `src/shared/db/settings.ts`
- `src/shared/payment-webhook-url.ts`
- The admin settings callers that construct the URL today.
- `test/lib/stripe/webhook.test.ts`
- A settings atomicity regression if needed.

Never delete endpoints for another URL. If endpoint listing fails, the recorded
endpoint cleanup and replacement must still proceed.

Do not subscribe to `checkout.session.expired`, add event-version settings, or
add first-request webhook reconciliation. Those require the staged runtime.

Acceptance:

- Same-URL stale endpoints are removed.
- Other URLs are untouched.
- Endpoint ID and secret cannot be partially saved.
- Listing failure follows the documented best-effort path.

### 5. Owner-safe links in attendee notes

**Branch:** `split/owner-note-links`

**Worktree:** `.pi-worktrees/owner-note-links`

Owner-only ledger links embedded in attendee notes remain links for owners and
become plain text for roles that cannot open them. Use the final token-aware
Markdown implementation immediately; do not port the earlier regex version.

Primary scope:

- `src/shared/markdown.ts`
- `src/ui/templates/admin/attendee-notes.tsx`
- Owner-role plumbing through attendee, listing overview, and roster note
surfaces.
- `test/shared/markdown.test.ts`
- Note and attendee page rendering tests.

The parser must handle inline, reference, collapsed-reference, shortcut, and
automatic links, including links nested in lists, blockquotes, and tables. It
must preserve safe Markdown and code spans.

Do not bring in deleted-listing display, pending-checkout UI, held-cash actions,
or other attendee-page changes from the mixed source commits.

Acceptance:

- Every rendered link is reachable by the viewer's role.
- Owners retain the ledger links.
- Non-owners retain readable note text and formatting without a dead link.

### 6. QR checkout error propagation

**Branch:** `split/qr-checkout-errors`

**Worktree:** `.pi-worktrees/qr-checkout-errors`

Pass the payment flow's error message and HTTP status through the QR checkout
route instead of replacing every error with a generic HTTP 500 page.

Primary scope:

- `src/features/public/qr-book.ts`
- `src/ui/templates/public/errors.tsx`
- `test/lib/server-qr-book.test.ts`

Keep main's current checkout creator and intent flow. Use an existing provider
validation error for the regression rather than importing staged checkout or
the staged sold-out preflight.

Acceptance:

- A provider validation refusal keeps its message and HTTP 400 status.
- A missing/null checkout result still renders the existing generic HTTP 500
response.
- No staged-checkout imports or schema changes are included.

## Merge order

The six PRs are independent enough to develop in parallel. Prefer merging in
this order when several become ready at once:

1. Stripe refund status correctness.
2. Atomic placeholder refund ledger.
3. Stage-neutral attendee purge unification.
4. Stripe webhook setup hardening.
5. Owner-safe links in attendee notes.
6. QR checkout error propagation.

Rebase each open PR after earlier ones merge, resolving toward one shared
mechanism rather than preserving parallel implementations.

## Next pull request

### Canonical paid booking rows and date fields

**Branch:** `split/booking-lines`

**Worktree:** `.pi-worktrees/booking-lines`

Build one canonical representation of the signed paid booking rows that both
ordinary payment completion and the later staged runtime can use. Move booking
date and duration rules into a pure shared helper at the same time: the row
builder depends directly on those rules, so splitting them would add ordering
without isolating meaningful risk.

Primary scope:

- Add `src/shared/booking-date-fields.ts`.
- Add `src/shared/booking-lines.ts`.
- Replace only the paid-row construction in
`src/features/api/payment-processing/create.ts`.
- Move the existing public and refund callers to the shared date helper without
changing their behavior.
- Make the existing order-parent allocation helpers preserve the full input
row type.
- Add focused pure tests for dates, package paths, allocations, order tokens,
and exact paid-price conservation.

Preserve current `main` behavior, especially its modular capacity imports,
shared response handler, atomic placeholder refund ledger, QR errors, and
owner-safe Markdown. Do not copy whole reference versions of payment or public
route files.

Acceptance:

- Existing paid bookings produce the same listing, quantity, date, duration,
package, allocation, and price rows as before.
- A zero paid price remains different from a missing paid price.
- Child allocations preserve total quantity and exact total paid price.
- Parent package stamping happens only when the parent has one unambiguous
package path.
- Legacy payment metadata without `day_count` still means one day.
- The PR has no checkout-stage schema, activation, refund, cleanup, backup, or
admin-lock changes.

## Remaining foundations

After the canonical booking-row PR, continue in this order:

1. Make ordinary booking writes all-or-nothing and recover completed payments
when the database result is lost. Add only the shared primary-read, SQL,
modifier, token, and batch-write mechanisms this production path uses; do
not ship a separate primitives-only API with no caller.
2. Split the backup modules without changing behavior. Preserve current main's
batched reads and `parseDateMs`.
3. Add dormant checkout-stage tables, revision triggers, rollback fences, and
certified backup snapshots.
4. Add one coherent staged-checkout runtime containing stage creation,
activation, refund lifecycle, cleanup, expiry handling, provider event
reconciliation, admin mutation guards, and matching UI/CSV projections.

Do not enable stage creation across separate deployments from its payment,
refund, cleanup, and mutation protections.

Before the staged runtime is mergeable, also resolve these findings from the
split review:

- One validation path currently asks the provider to refund before durably
changing the stage to `refunding`.
- Old `refunding` stages have no bounded reconciliation path.
- Admin table/CSV pending projection must use the same open-state definition as
mutation guards, including `refunding`.
- A paid unresolved stage must not be purged merely because it is seven days
old while the provider may still hold the money.
- Product policy must explicitly confirm what happens when a cancelled local
checkout later receives a provider payment that could not be expired.
Loading