Repository navigation
Money: a card refund is credited back to the child, and each card capture or refund posts once - #925
NewWorldVenture wants to merge 14 commits into
Conversation
Stripe sends Issuing refunds as issuing_transaction.created with
type 'refund' and a positive amount ("reflected in your balance";
captures are negative). handleTransactionCreated took Math.abs(amount)
and posted a card_spend debit for anything non-zero.
Through the real /api/webhooks/money route, with events signed by
stripe-node's own test signer and an in-memory store: a child with $50
in Spend buys $20 and is refunded $20, and is left with $10, not $50.
A $7.50 partial refund leaves $22.50 instead of $37.50.
Six cases fail on main (full refund, the row written, partial refund,
redelivery, a second event id for the same refund, the audit row).
Four pass and must keep passing: a refund lands in Spend whatever the
split, a capture debits once and releases its hold, a zero amount
writes nothing, an unknown card fails with 500 and writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Stripe's Issuing transaction amount is what the transaction does to the balance: negative for a capture, positive for a refund. handleTransactionCreated debited Math.abs(amount) either way, so a merchant refund took the money out a second time. It now goes by that sign: a negative amount is debited as before; a positive amount is credited through a new creditCardRefund, which posts one completed card_refund credit to the child's Spend bucket, idempotent on the Stripe transaction id like the debit, and audited. It does not go through creditChildWallet: a refund is the purchase undone, so it lands where the purchase's debit was rather than being split across Save, Give and Invest as new money. The hold release is unchanged. A failed refund write throws, so the route answers 500 and Stripe retries. Two cases added with the fix: a zero amount is acknowledged (200) and writes nothing; a refund whose duplicate check cannot be read fails with 500 and writes nothing. No schema, migration or route change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Self-review of #925, finding 1: handleTransactionCreated released the authorization hold for every Issuing transaction, refunds included. Stripe does not promise event order, so a refund processed before its capture cancelled the $20 hold AND credited the $20 refund: spendable (completed + processing, what wallet_reserve_card_auth decides on) went from $30 to $70, a $70 authorization would be approved, and the late capture left Spend at -$20. The release now runs only in the capture branch; a refund leaves any hold to its capture or to issuing_authorization.updated. A refund also records the authorization it belongs to in its metadata (null when Stripe sends it unlinked), so reconciliation can match it to the purchase it undoes. lib/wallet/server.ts's header no longer calls creditChildWallet the only credit writer. Two cases added, both failing before this commit: refund before capture keeps the hold and ends at $50 spendable after both; the refund row names its authorization. 15 mutants of the refund path, all killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…ccess Review 4174926185 on #925: creditCardRefund's duplicate check is a select and then an insert, so two refund deliveries for one Stripe transaction that overlap (two event ids, or a stale-claim reclaim) both pass the select. With the database key the review asks for (a unique index on stripe_ref for card_refund rows; number requested in #699, 5974164780, and not stamped until allocated) the second insert fails with 23505. creditCardRefund now reads the refund that insert lost to and answers success, so the second delivery is acknowledged rather than retried, and the child is credited once. A 23505 with no refund to read back is still a failure. Tests: two overlapping deliveries under different event ids, with the store given the same unique key; both reach the insert (counted), both answer 200, one refund row, Spend $50 (failed with a 500 before this commit). A 23505 from another key still answers 500 and writes nothing. Three mutants of the conflict path, all killed. Without the index this handling never runs and the race remains; the migration is what closes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Owner review 4174926185 on #925 asked for the refund transaction key to be enforced in the database. Number 0484 reserved on #699 (5974208357); filename coordination only — not an instruction to apply to production. One statement: a partial unique index on wallet_transactions(stripe_ref) where type = 'card_refund' and stripe_ref is not null. card_spend is left alone (a hold and a replayed hold share a ref), NULL refs are skipped, and nothing wrote card_refund before #925, so no existing row can conflict. Proved on a throwaway Postgres 16 with every migration replayed through 0484 (docs/audit/pg-bootstrap.sh): a second card_refund with the same ref is refused (23505); two card_spend rows sharing a ref and two card_refund rows with no ref are accepted; two sessions inserting the same refund — the second waited ~2 s on the first's open transaction, then failed 23505 once it committed, leaving one row. With the index, creditCardRefund's 23505 handling (previous commit) answers that second delivery as the first one's success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
… child twice The debit side of owner review 4174926185 on #925. debitCardSpend checks for an existing capture with a select and then inserts, so two claims of one capture that overlap (two event ids for one transaction, or recordEvent reclaiming a claim its first holder is still running) both pass the select. With no key, one $20 purchase delivered twice leaves Spend at $10 instead of $30. The store is given the database key requested on #699 (a unique index on stripe_ref for completed card_spend rows). The overlapping case fails until the second delivery reads back the capture the first wrote: it answers 500 today. The control, a 23505 from another key, already answers 500 and leaves the hold in place, and must keep doing so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…ebit debitCardSpend's duplicate check is a select and then an insert. With the database key requested on #699 (one completed card_spend per stripe_ref), the losing insert of two overlapping claims fails with 23505. debitCardSpend now reads back the completed capture that won and answers success, so the second delivery is acknowledged, the hold is released once, and the child is debited once. A 23505 with no capture to read back is still a failure, so Stripe retries. This mirrors creditCardRefund's handling (dac7b53). The key itself is the migration that follows. Until it is applied, the conflict never fires and the race remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
A partial unique index, uq_wallet_txn_card_capture_ref, on wallet_transactions (stripe_ref) where type = 'card_spend' and status = 'completed' and stripe_ref is not null. It makes the capture key the database's, so the second of two overlapping inserts fails with 23505, which debitCardSpend now answers by reading back the first. The key covers completed captures only: - an authorization's hold is a processing card_spend keyed by the authorization id, and 0342 records that a replay can place another; - a spend request is a card_spend with no stripe_ref. Replayed on a throwaway local PostgreSQL 16 (all migrations, then this one), with every existing ledger shape seeded first: - a second completed capture for one ref is refused with 23505; - holds, cancelled holds and a replayed hold for one authorization, null- ref spend requests and their approval, and a refund sharing the capture's ref are all accepted; - two concurrent sessions: the second waited on the first and got 23505, leaving one row; - with an existing duplicate, the build fails and the readiness query in the header names it. Number 0485 was reserved on #699 (5974392907), for filename coordination only. Not applied to any hosted database. The version pin moves to 0486 on this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…vers From an independent review of the capture and refund conflict paths. - debitCardSpend's duplicate check now filters on status = 'completed', the 0485 key's own predicate. On a local PostgreSQL 16 with the index built, that query is an index scan on uq_wallet_txn_card_capture_ref; the old (stripe_ref, type) query was a sequential scan even with enable_seqscan off. A hold is keyed by the authorization id, and only this function writes a completed card_spend under a transaction id, so no existing row is treated differently. - When the read-back after a 23505 itself fails, that error is the one logged and returned, for captures and refunds alike. Before, only the 23505 reached the log, which reads as a harmless race. Tests (all through the real webhook route): - a 23505 where the only same-ref card_spend is not completed fails and keeps the hold; - a failing read-back fails, logs its own error, keeps the hold (capture) and credits nothing (refund); - the overlap test counts one card_spend audit row; - the 0484 and 0485 index statements are pinned exactly, since the in-memory store cannot express a partial key. The test comment no longer calls the store's wider key "the same key". Mutation: 5 mutants, all killed (read-back without the status filter, pre-check without it, either read-back error swallowed, an audit row written on the read-back path). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
… invalid index Comment-only; neither index statement changes. - Both apply notes offer the CONCURRENTLY form. If that build fails (an existing duplicate, a cancelled statement), it leaves an INVALID index of the same name, and a rerun of the file's `if not exists` then skips it and enforces nothing. The notes now say to drop it first and how to list invalid indexes. - 0485's header said holds are "never completed". 0155's header allows a hold to move processing -> completed/cancelled; no code completes one today. The note now says so, and that a future path completing holds must keep to one completed row per authorization id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
Review finding: the new |
Owner review 5976939812. Commit 2 makes a refund a card_refund CREDIT, and two readers counted every completed credit as money coming in: - app/(app)/wallet/treasury/page.tsx: "this month in", each child's "+$X this month" and the 6-month trend; - app/api/ai/wallet/route.ts: the coach's 8-week contribution rate, which forecasts how many weeks a goal is away. lib/wallet/flows.ts moneyFlow() is the one rule both now use: a credit is in, a debit is out, and a card refund nets against out. "In" stays new money, "out" becomes net spending, and the treasury's Net (in − out) is unchanged. The coach counts only what is in, so a returned purchase no longer shortens a goal's forecast. Balances are untouched: for a balance a refund is an ordinary credit. Not changed: - app/api/ai/wallet/child/[childId]/route.ts counts only Save-bucket credits, and refunds go to Spend; - the admin and reconciliation "credit volume" totals are gross volume by name. Tests, driving the real page and route against an in-memory store: - treasury: a $50 top-up, a $20 purchase and its $20 refund give in $50, out $0, net +$50, the same per child and in the trend; on the old code in was $70; - coach: $80 of allowance and a $40 refund over 8 weeks forecast a $100 goal at 10 weeks; on the old code it was 7. Mutation: 3 mutants, all killed (per-child line reverted, trend reverted, refund dropped instead of netted). Scope claimed on #771 (5976950098) before editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
Addressed in
Generated by Claude Code |
|
Composition dependency is now ready: #942138f9eac38ac4a51d22d843b668b41ef74aad264 merged externally at11:57:49 UTC (1e8e8e7), satisfying the condition in your #6995977042630 ownership offer. Support, take the bounded #925 treasury composition in your audit branch after checking current main and active claims. Preserve #942's family-timezone day keys AND #925's moneyFlow/refund accounting; do not replace the whole page or drop either behavior. Current #925 reports mergeable=false, but inspect actual conflict before attributing it. Run focused two-family/timezone and capture/refund trend regressions plus affected type/lint/tests on the composed head, then fresh CI. Preserve your current card-hold child branch work and coordinate its dependency on this updated parent; no competing writer in the same file. Report exact head and resolved hunks, retain known residuals. This is source/test branch composition only, no PR merge, production migration, live financial operation or deployment. |
… netting One conflict, app/(app)/wallet/treasury/page.tsx, in the 6-month trend loop. #942 (merged as 1e8e8e7) keys each transaction by the family's month; #925 counts it through moneyFlow, so a card refund nets against out. The resolution keeps both: if (t.status !== 'completed' || monthKeyOf(t.created_at) !== key) continue; const flow = moneyFlow(t); credits += flow.inCents; debits += flow.outCents; The this-month loop and the per-child lines merged without conflict: #942's family-zone monthStart with #925's moneyFlow sums. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
The same ledger rendered for a family in Los Angeles and one in Tokyo, with "now" fixed at 2026-10-15T12:00Z and a refund at 05:00Z on 1 October: - Los Angeles (still 30 September): September in $50, out $0, since the refund undoes September's $20 purchase; October in $10, out $0. - Tokyo (already 1 October): September in $50, out $20; October in $10, out -$20; net +$30, October's real change. Pins both halves of the merge. Trend keyed by UTC instead of the family's zone: 1 failure. Trend without moneyFlow (main's side of the hunk): 3 failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
Support: #942 composed into #925 (answers 5979697763). Head
Generated by Claude Code |
|
Independent money-handling review on exact PR head 2113c90: lib/stripe/webhook.ts currently selects the capture/refund handling path from the sign of amount. The negative-amount branch posts card_spend and releases the authorization hold without checking transaction type. Stripe documents that an Issuing Transaction with type=refund may have a negative amount for a refund reversal (https://docs.stripe.com/api/issuing/transactions/object). If that reversal arrives before its capture event, this path can release the pending authorization hold prematurely. The added coverage includes positive refunds and negative captures, but no negative type=refund reversal or out-of-order reversal/capture case. Please classify the event by transaction type as well as amount, and add a regression that pins the expected ledger and hold state for a negative refund reversal arriving before its capture. This is a synthetic code/test finding; no money or production data was touched. |
Owner review 5979998503. Stripe documents that an Issuing transaction of type `refund` can carry a negative amount: a refund reversed. handleTransactionCreated chose its branch by the amount's sign alone, so a reversal was debited (right: money goes out) and also released the linked authorization's hold (wrong). Arriving before its capture, the reversal freed the held money while the capture was still to come. The sign still decides which way money moves; the type now decides whether a hold is touched. Only `type === 'capture'` releases it. Tests (signed events through the real route): - a $5 reversal before its $20 capture is debited and leaves the hold held (spendable $25 of $50); red before this commit, with the hold cancelled; - the capture then releases the hold, and Spend ends at $25. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
|
Addressed in
Generated by Claude Code |
|
Closing again at the owner's direction to close the 85-PR backlog: this PR was in the sweep at 15:51 UTC and was reopened at 16:13 UTC. The branch is kept; nothing is merged or deleted. Please do not reopen without the owner's say-so. Generated by Claude Code |
…tes and add up every row
Twenty confirmed money findings (M1-M20), each with a negative-control test
that drives the real page, component, action, route or service.
- M1 family-cfo assessAffordabilityAction applies its page's gates:
readsHouseholdMoney (guests too), resolveFeatureEntitlement for
/dashboard/family-cfo, and the aal2 step-up verdict (stepUp path, which
the form now follows through reportRefusal).
- M2 saveAllowanceRuleAction refuses a NEW rule for a child who already has
one; the allowance page lists every rule a wallet has (each pausable)
and says when a child has more than one.
- M3/M5 Budget Planner reads the year's transactions whole
(readAllByIdAsQuery, ceiling 50,000, failing), from this week's Sunday
(which can be in December) to the end of the year.
- M4 Expenses reads expense_splits and expense_split_shares whole
(readAllByIdAsQuery), listing splits newest first in memory.
- M6 handleTransactionCreated branches on Stripe's capture/refund: a refund
is a card_refund credit to Spend (creditCardRefund), a refund reversal
is debited, only a capture releases its hold, an unknown type throws.
Same handler change as draft PR #925, without its migrations.
- M7 /dashboard/money-timeline tells a non-manager whose the money is
instead of showing Liquid today and the projected low.
- M8 /wallet/babysitters reads and shows payouts only to a parent or adult.
- M9 /api/ai/wallet, /api/ai/wallet/child/[childId] and /api/ai/invest
answer a guest 403 before any read or model call.
- M10 wallet activation provisions child and teen members only; a guest is
refused requestAllowanceAction, requestSpendAction and
placeInvestOrderAction.
- M11 /api/ai/savings judges each budget (canonical, case-insensitive) in its
own period on the family's calendar, both ends bounded.
- M12 budgetVsActual's totals are the month's: limits as monthly shares,
against the month's spending in budgeted categories; the AI money line
says so; the Finances budget bar uses the monthly shares.
- M13 Family CFO's Spending vs budget uses each canonical budget's monthly
share, matched case-insensitively.
- M14 budgetSpent and the Finances month bound both ends of the period.
- M15 a credit account's balance lowers Total balance on /wallet and Finances.
- M16 AI Finance Insight fails closed on either read and sums total_cents.
- M17 AI spending insights fails closed on the expense_splits read.
- M18 the interactive brief answers 503 on a bills read error.
- M19 the allowance cron reads managers in chunks of families, each paged to
its end; a failed read fails the run.
- M20 the approve/decline call is bounded to Stripe's window (1.5s, no
retries); a failed approval releases its hold only when Stripe reports
the authorization closed; issuing_authorization.created is handled like
.updated, releasing a hold Stripe declined by its timeout setting.
New pure helpers live in lib/finance/budget-periods.ts (no local calendar
reads, so server paths can use them); three Playwright fixtures list it.
Existing tests that pinned the old behaviour now pin the decided one: a child
is not shown the money timeline (0514 over 0267), a guest may not ask for an
allowance or to spend, and a new allowance for a child with one is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…migration number The comments cited closed draft #925's migration 0484, a number that now belongs to a different migration. They now say the unique keys (one card_refund, and one capture, per stripe_ref) are owner SQL not yet applied. Comment-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Draft for review: do not merge without the owner. It was claimed before editing in #771 (5971699986), and it is its own branch from main
40bf5ff36.What was wrong
Stripe sends every Issuing transaction as
issuing_transaction.created, withtype: 'capture' | 'refund'. Itsamountis "the transaction amount, which will be reflected in your balance" (stripe-nodeIssuing.Transaction): negative for a capture, positive for a refund.handleTransactionCreatedinlib/stripe/webhook.tstookMath.abs(txn.amount)and posted acard_spenddebit for anything non-zero. So a merchant refund took the money out a second time.This was reproduced through the real
/api/webhooks/moneyroute. The events were signed by stripe-node's own test signer, so the real signature check ran, followed by the real handlers and wallet helpers, against an in-memory store:The
card_refundtype exists in the enum and the activity labels, but nothing wrote it. No test covered a refund.The change
lib/stripe/webhook.ts,handleTransactionCreatedonly. It now goes by the sign Stripe defines:In this commit the hold release was left as it was; commit 3 (below) makes it capture-only, so a refund no longer releases a hold. A failed refund write throws, so the route answers 500 and Stripe retries, the same as a failed debit.
lib/wallet/server.ts, newcreditCardRefundbesidedebitCardSpend(untouched in this commit; commits 7 and 9 change only its conflict path).card_refundcredit to the child's Spend bucket.card_refund.creditChildWallet, whose split would scatter a refund across Save, Give and Invest. A refund is the purchase undone, so it lands where the purchase's debit was.The webhook route is unchanged. Commits 5 and 8 add two repository migration candidates, each a partial unique index. Commit 11 changes two readers of the ledger. Both are described below.
Tests
tests/a-card-refund-credits-the-child.test.tscontains signed events through the real route, with no network calls.Commit 1,
0e53c7c4f(tests only). On main, 6 cases fail:4 cases pass and must keep passing:
Commit 2,
35b966966(the fix). All 11 cases pass: the 10 above plus a duplicate-check read failure, which answers 500 and writes nothing. The zero case now also asserts the 200.Mutation check. 13 hand mutants, all killed:
card_spend;processing;Local validation on
35b966966:tsc --noEmitclean;npm run lint0 errors; every test file touchinglib/stripe/webhook,lib/wallet/serveror the money route (29 files, 539 tests) passes; fullvitest run1,874 files passed (1 skipped), 25,418 tests passed, 2 expected-fail, 73 skipped.Commit 3,
f18973454(independent self-review of this PR, before any human review).processingand is whatwallet_reserve_card_authdecides on, went from $30 to $70. A $70 authorization would then be approved, and the late capture would leave Spend at -$20. A refund now leaves any hold to its capture, or toissuing_authorization.updated.metadata.authorization(nullwhen Stripe sends it unlinked), so reconciliation can match a refund to its purchase.lib/wallet/server.ts's header no longer callscreditChildWalletthe only credit writer.f18973454:tsc --noEmitclean;npm run lint0 errors; fullvitest run1,874 files passed (1 skipped), 25,420 tests passed, 2 expected-fail, 73 skipped.Commit 4,
dac7b5343(owner review 4174926185): the refund key in the database. The duplicate check is a select followed by an insert. Two overlapping deliveries of one refund, under two event ids or through a stale-claim reclaim, therefore both pass the select.uq_wallet_txn_card_refund_refonwallet_transactions (stripe_ref) where type = 'card_refund'. Its migration number was requested in feat(admin-digest): per-recipient delivery engine with fenced claims, frozen payloads and retention-bounded retries (not wired) #699 (5974164780) and allocated there (5974208357); the file is commit 5, below.23505.creditCardRefundthen reads back the refund that won and answers success, so the second delivery is acknowledged rather than retried, and the child is credited once. A23505with no refund to read back is still a failure.23505from another key still answers 500 and writes nothing.dac7b5343:tsc --noEmitclean,npm run lint0 errors, fullvitest run1,874 files passed (1 skipped), 25,422 tests passed, 2 expected-fail, 73 skipped.Commit 5,
1e296b83e:supabase/migrations/0484_a_card_refund_is_credited_once.sql. This is a repository candidate only. It has not been applied to any hosted database, and applying it to production is the owner's decision.docs/audit/pg-bootstrap.sh, then 0484):card_refundwith the samestripe_refis refused with23505;card_spendand acard_refundsharing onestripe_refare both accepted;card_refundrows with a nullstripe_refare both accepted;23505, leaving one row.card_refundpair would makecreate unique indexfail. Nothing in the application wrotecard_refundbefore this PR, so none should exist. An operator can confirm withselect stripe_ref from wallet_transactions where type = 'card_refund' and stripe_ref is not null group by 1 having count(*) > 1;before applying.tests/migration-version-safety.test.tsmoves to 0485 on this branch, with the same note every migration PR adds.Commits 6–8: the same race on the capture side (scope extension claimed in #771, 5974374817).
debitCardSpendchecks for a capture with a select and then inserts, exactly as the refund did. Two overlapping claims of one capture both pass the select. Reproduced through the real route: one $20 capture delivered twice, overlapping, answered 200 twice and left Spend at $10 instead of $30.b995e708c(test): overlapping capture deliveries, with the store given the database key. The test counts the inserts to prove the overlap. It answered[200, 500]before commit 7. A control, a23505from another key, answers 500 and leaves the hold in place.5e041781b(fix): on23505,debitCardSpendreads back the completed capture that won and answers success. The second delivery is then acknowledged, the hold is released once and the child is debited once. This mirrorscreditCardRefund.lib/stripe/webhook.tsis unchanged.c79fe1f00:supabase/migrations/0485_a_card_capture_is_debited_once.sql, slot allocated on feat(admin-digest): per-recipient delivery engine with fenced claims, frozen payloads and retention-bounded retries (not wired) #699 (5974392907):status = 'completed': they areprocessinguntil released tocancelled, and 0342 records that a replay can place a second hold.stripe_ref.23505;23505, leaving one row;card_refund, completed captures already exist. A duplicate in production would be a child debited twice for one purchase, and would make the build fail. The file's header carries the query to run first. Correcting any rows it finds is an operator decision.23505branch, success without a row, reading back a refund, reading back the hold's status. One is equivalent: reading back after any error code, which still answers success only when the completed capture exists.Commits 9–10: from an independent review of the capture and refund conflict paths.
c127f28cb(fix):debitCardSpend's check before the insert now filters onstatus = 'completed', the 0485 key's own predicate. On a local PostgreSQL 16 with the index built, that query is an index scan onuq_wallet_txn_card_capture_ref. The old(stripe_ref, type)query was a sequential scan even withenable_seqscanoff. Holds are keyed by the authorization id, so no existing row is treated differently.23505fails, that error is the one logged and returned, for captures and refunds. Before, only the23505reached the log, which reads as a harmless race.23505where the only same-refcard_spendis not completed fails and keeps the hold. A failing read-back fails and logs its own error. The overlap test counts one audit row. The 0484 and 0485 index statements are pinned exactly, because the in-memory store cannot express a partial key.73ebbd82d(comments only):CONCURRENTLYbuild leaves an INVALID index of the same name, which a rerun ofif not existssilently skips. Both notes now say to drop it first.processing→completed/cancelled, and no code completes one today.73ebbd82d:tscis clean, lint has 0 errors (its 15 warnings are all in files this PR does not touch), the i18n gate is clean, and the full vitest suite passes: 1,874 files, 25,429 tests. The card test file has 22 tests.Commit 11,
fbe64d82b: a refund is spending undone, not new money (owner review 5976939812; scope claimed on #771, 5976950098).card_refundcredit. Two readers counted every completed credit as money coming in:app/(app)/wallet/treasury/page.tsx): "this month in", each child's "+$X this month" and the 6-month trend;app/api/ai/wallet/route.ts): the 8-week contribution rate behind each goal'sweeksToGoal.moneyFlow()in the newlib/wallet/flows.ts, used by both readers.tests/a-card-refund-is-not-new-money.test.tsdrives the real page and the real route against an in-memory store.app/api/ai/wallet/child/[childId]/route.ts) counts only Save-bucket credits, and refunds go to Spend.moneyFlowinside it.fbe64d82b:tscis clean, lint has 0 errors (its 15 warnings are all in files this PR does not touch), the i18n gate is clean, and the full vitest suite passes: 1,875 files, 25,432 tests.Commits 12–14: #942 composed in, and a refund reversal.
8e07c578a: a normal merge of main, which brings in A server page renders the family's date: 24 zone-less date renders on 13 family server files, and the three emails take the family's zone #942's family-zone treasury.moneyFlowinside it. The this-month loop and the per-child lines merged without conflict.2113c903b: a two-zone regression.moneyFlowfails it.356a861b0: only a capture releases its authorization's hold (owner review 5979998503).refundwith a negative amount: a refund reversed. Classified by sign alone, it was debited and also released the purchase's hold, so arriving before its capture it freed held money.356a861b0:tscis clean, lint has 0 errors, the i18n gate is clean, and the full vitest suite passes: 1,895 files.Not covered (residuals, not closed by this)
a refund leaves the purchase's hold to the purchasein the test file).card_spenddebit and then failing beforeprocessed, would be credited on retry. The wrong debit (keyed to the refund's transaction id) would stay, so the child nets zero instead of the refund. Such rows have exactly that signature, acard_spendwhosestripe_refis a refund transaction. Finding and correcting them is the same operator action as the earlier refunds below.typedecides only whether a hold is released (commit 14). A negative-amount Striperefundis therefore stored as a debit (card_spend) that touches no hold, and a positive amount is credited as a refund whatever its type.transfer,bucket_transfer,goal_transfer) andreversalcredits are still counted as "in" by the treasury and the coach, as they were before this PR.wallet_reserve_card_auth(0155/0342) keys a hold by its authorization id, with an in-lock check scoped toprocessing. 0342 records that a replay after capture places a fresh hold. That path is unchanged here, and 0485 leaves holds out by design.lib/stripe/webhook.ts, outside this PR's file claim). These were found by the review above and not changed here:handleAuthorizationUpdatedreleases the hold when the authorization becomesclosed. Stripe does not order that event beforeissuing_transaction.created, so the held money can become spendable before the capture debit is written.stripe_ref, as the check before the insert already did. Stripe transaction ids are unique, so a row from another child would have to be forged by someone who can already write that family's ledger.🤖 Generated with Claude Code
https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Generated by Claude Code