Skip to content

[enterprise][P0 CRITICAL] walletCredit increments a NULL walletBalance, so the cached balance stays NULL while the ledger is credited (ledger-vs-cache drift on any account created without a zero balance) #1459

Description

@teetangh

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EnterpriseEnterprise tier — B2B org features, Architecture 4bugSomething isn't workingfinancePayments, refunds, earnings, payouts, invoicing, ledgerlaunch: pre-mvpGates launch — money, data, or a failure we would not detectpriority: critical

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions