Found 2026-09-05 in wave-1C E2E on the deploy preview of #1422. sweep-orphaned-topup-captures recredited WalletTopUp test-topup-wave1c-001 for billing account b19a5889-2acf-490f-8632-ff0b4ddc0c66 (org 161fb79c-5631-4ca3-944c-9ec9746efe72, INVOICE-funded): the LedgerTransaction/LedgerEntry CREDIT 123400 to the org WALLET account posted correctly, but BillingAccount.walletBalance stayed NULL. Cause: lib/api/organizations/wallet.ts ~L119-124 walletCredit does data: { walletBalance: { increment: amountPaise } } on the assumption the column is never NULL (comment at ~L116-118); Postgres NULL + x = NULL, so the cached column silently no-ops and balanceAfter reads walletBalance ?? 0. Every INVOICE-funded account today has walletBalance NULL (2 of 2), so any wallet-credit path against one (refund to wallet, top-up sweep, admin credit) produces permanent cache-vs-ledger drift that only the reconciler notices.
Fix (orchestrator decision): make the credit null-safe inside the same transaction — first updateMany({ where: { id, walletBalance: null }, data: { walletBalance: 0 } }) then the increment (CAS on NULL, no raw SQL) — and apply the same to every debit/credit path in wallet.ts; add a pin (NULL cached balance + credit → cached == ledger). Do not change the schema in this PR (a non-null default is the pre-MVP reset's job; note it in the docs). The drifted throwaway account above is mock data; repair its cached balance to the ledger value after the fix lands.
Related P1/P2 from the same run (same PR): reconcile-disputes route hardcodes status 200 (app/api/cleanup/reconcile-disputes/route.ts:30) masking success:false — same class as the #1390 fix; abandoned-payments takes ~6.2 s on five stuck payments (sequential gateway cancels in scripts/payments/cleanup-abandoned-payments.ts ~L498) so the ticker's 6 s per-target budget aborts it every tick; reconcile-orphaned-confirmations keeps its own parseLimit instead of the shared parseLimitParam (no 400, no clamp); /api/webhooks/razorpay accepts a 1 MB body (no size cap).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
Found 2026-09-05 in wave-1C E2E on the deploy preview of #1422. sweep-orphaned-topup-captures recredited WalletTopUp test-topup-wave1c-001 for billing account b19a5889-2acf-490f-8632-ff0b4ddc0c66 (org 161fb79c-5631-4ca3-944c-9ec9746efe72, INVOICE-funded): the LedgerTransaction/LedgerEntry CREDIT 123400 to the org WALLET account posted correctly, but BillingAccount.walletBalance stayed NULL. Cause: lib/api/organizations/wallet.ts ~L119-124 walletCredit does
data: { walletBalance: { increment: amountPaise } }on the assumption the column is never NULL (comment at ~L116-118); Postgres NULL + x = NULL, so the cached column silently no-ops and balanceAfter readswalletBalance ?? 0. Every INVOICE-funded account today has walletBalance NULL (2 of 2), so any wallet-credit path against one (refund to wallet, top-up sweep, admin credit) produces permanent cache-vs-ledger drift that only the reconciler notices.Fix (orchestrator decision): make the credit null-safe inside the same transaction — first
updateMany({ where: { id, walletBalance: null }, data: { walletBalance: 0 } })then the increment (CAS on NULL, no raw SQL) — and apply the same to every debit/credit path in wallet.ts; add a pin (NULL cached balance + credit → cached == ledger). Do not change the schema in this PR (a non-null default is the pre-MVP reset's job; note it in the docs). The drifted throwaway account above is mock data; repair its cached balance to the ledger value after the fix lands.Related P1/P2 from the same run (same PR): reconcile-disputes route hardcodes status 200 (app/api/cleanup/reconcile-disputes/route.ts:30) masking success:false — same class as the #1390 fix; abandoned-payments takes ~6.2 s on five stuck payments (sequential gateway cancels in scripts/payments/cleanup-abandoned-payments.ts ~L498) so the ticker's 6 s per-target budget aborts it every tick; reconcile-orphaned-confirmations keeps its own parseLimit instead of the shared parseLimitParam (no 400, no clamp); /api/webhooks/razorpay accepts a 1 MB body (no size cap).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7