From 9a8746883dad89dbfb5db7205e7e5019d5249446 Mon Sep 17 00:00:00 2001 From: NubsCarson Date: Thu, 2 Jul 2026 05:23:19 +0000 Subject: [PATCH] fix(cloud): persist Stripe Connect capability booleans so fiat payouts aren't permanently rejected (#11172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Connect webhook derived `status` from the account.updated capability booleans but DROPPED the booleans themselves. `payouts_enabled` defaults false (migration 0150) and the payout transfer gate reads it directly (transfer/route.ts: `if (account.status !== "active" || !account.payouts_enabled)`), so after a creator fully onboards (Stripe sends charges_enabled=true, payouts_enabled=true) we stored status='active' but left payouts_enabled=false forever → every Stripe Connect fiat payout permanently rejected, no recovery path. Launch-blocking for the fiat payout rail. Fix (option a — keep the column truthful): mapConnectWebhookEvent now returns the raw chargesEnabled/payoutsEnabled from account.updated, and the webhook route persists them alongside status via updateByAccountId (which already accepts the columns). The column now reflects reality — true when Stripe reports the caps, false when it doesn't. Tests: - stripe-connect-payout.test.ts: mapper surfaces the booleans (both true → also active; charges true / payouts false → surfaced truthfully). - stripe-connect-webhook-route.test.ts: the account.updated persist now asserts { status: "active", charges_enabled: true, payouts_enabled: true } — the regression guard (was { status: "active" } alone, which left the column false). Money-path — flagging for maintainer review/merge. [cloud-security] --- .../stripe-connect-webhook-route.test.ts | 7 ++++++- .../payout/stripe-connect/webhook/route.ts | 9 +++++++++ .../lib/services/stripe-connect-payout.test.ts | 17 ++++++++++++++++- .../src/lib/services/stripe-connect-payout.ts | 17 +++++++++++++++-- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/cloud/api/__tests__/stripe-connect-webhook-route.test.ts b/packages/cloud/api/__tests__/stripe-connect-webhook-route.test.ts index f4b51a41309ca..da1e4b08af50e 100644 --- a/packages/cloud/api/__tests__/stripe-connect-webhook-route.test.ts +++ b/packages/cloud/api/__tests__/stripe-connect-webhook-route.test.ts @@ -180,9 +180,14 @@ describe("Stripe Connect payout webhook route", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ success: true }); - // account.updated with charges+payouts enabled => "active" + // account.updated with charges+payouts enabled => "active", AND the raw + // capability booleans are persisted (#11172): the payout transfer gate reads + // payouts_enabled directly (defaults false), so storing status alone left + // every account non-payout-ready forever. The column must be written true. expect(updateByAccountId).toHaveBeenCalledWith("acct_creator_1", { status: "active", + charges_enabled: true, + payouts_enabled: true, }); expect(emitAudit).not.toHaveBeenCalled(); }); diff --git a/packages/cloud/api/v1/earnings/payout/stripe-connect/webhook/route.ts b/packages/cloud/api/v1/earnings/payout/stripe-connect/webhook/route.ts index 3c71545c11f2c..b023c0aec3753 100644 --- a/packages/cloud/api/v1/earnings/payout/stripe-connect/webhook/route.ts +++ b/packages/cloud/api/v1/earnings/payout/stripe-connect/webhook/route.ts @@ -108,6 +108,15 @@ async function handlePOST(c: AppContext): Promise { await stripeConnectAccountsRepository.updateByAccountId(outcome.accountId, { ...(outcome.status ? { status: outcome.status } : {}), + // Persist the capability booleans, not just the derived status: the payout + // transfer gate reads `payouts_enabled` directly and it defaults false, so + // storing status alone left every account non-payout-ready forever (#11172). + ...(outcome.chargesEnabled !== undefined + ? { charges_enabled: outcome.chargesEnabled } + : {}), + ...(outcome.payoutsEnabled !== undefined + ? { payouts_enabled: outcome.payoutsEnabled } + : {}), }); logger.info("[StripeConnect] webhook applied", { type: event.type, diff --git a/packages/cloud/shared/src/lib/services/stripe-connect-payout.test.ts b/packages/cloud/shared/src/lib/services/stripe-connect-payout.test.ts index 550d28070d082..2b2582f44d3a9 100644 --- a/packages/cloud/shared/src/lib/services/stripe-connect-payout.test.ts +++ b/packages/cloud/shared/src/lib/services/stripe-connect-payout.test.ts @@ -165,13 +165,28 @@ describe("mapConnectWebhookEvent (#8922)", () => { }); }); - it("refreshes account status on account.updated", () => { + it("refreshes account status AND surfaces the capability booleans on account.updated (#11172)", () => { const out = mapConnectWebhookEvent({ type: "account.updated", account: "acct_1", data: { object: { charges_enabled: true, payouts_enabled: true } }, }); expect(out.status).toBe("active"); + // #11172: the booleans MUST be returned so the route persists them — the + // payout gate reads payouts_enabled directly (defaults false). Deriving + // status alone left every account non-payout-ready forever. + expect(out.chargesEnabled).toBe(true); + expect(out.payoutsEnabled).toBe(true); + }); + + it("surfaces false capabilities too (payouts not yet enabled → column stays false, truthfully) (#11172)", () => { + const out = mapConnectWebhookEvent({ + type: "account.updated", + account: "acct_1", + data: { object: { charges_enabled: true, payouts_enabled: false } }, + }); + expect(out.chargesEnabled).toBe(true); + expect(out.payoutsEnabled).toBe(false); }); it("ignores unrelated event types", () => { diff --git a/packages/cloud/shared/src/lib/services/stripe-connect-payout.ts b/packages/cloud/shared/src/lib/services/stripe-connect-payout.ts index d724a11a8500d..3fb1ff514abd4 100644 --- a/packages/cloud/shared/src/lib/services/stripe-connect-payout.ts +++ b/packages/cloud/shared/src/lib/services/stripe-connect-payout.ts @@ -155,6 +155,15 @@ export interface ConnectWebhookOutcome { payoutStatus?: ConnectPayoutStatus; /** Account capability refresh, for `account.updated`. */ status?: StripeConnectStatus; + /** + * Raw capability booleans from `account.updated`. Persisted alongside `status` + * so the DB column reflects reality: the payout transfer gate reads + * `payouts_enabled` directly, and it defaults false — deriving only `status` + * from these and dropping the booleans left `payouts_enabled` false forever, + * rejecting every fiat payout (#11172). + */ + chargesEnabled?: boolean; + payoutsEnabled?: boolean; /** True when the event type isn't one we act on. */ ignored: boolean; } @@ -175,12 +184,16 @@ export function mapConnectWebhookEvent(event: { return { accountId: event.account, payoutStatus: "paid", ignored: false }; case "account.updated": { const obj = event.data?.object ?? {}; + const chargesEnabled = obj.charges_enabled === true; + const payoutsEnabled = obj.payouts_enabled === true; return { accountId: event.account, status: connectStatusFromCapabilities({ - charges_enabled: obj.charges_enabled === true, - payouts_enabled: obj.payouts_enabled === true, + charges_enabled: chargesEnabled, + payouts_enabled: payoutsEnabled, }), + chargesEnabled, + payoutsEnabled, ignored: false, }; }