diff --git a/server/src/external/svix/svixHelpers.ts b/server/src/external/svix/svixHelpers.ts index b3794da76..6e8219d82 100644 --- a/server/src/external/svix/svixHelpers.ts +++ b/server/src/external/svix/svixHelpers.ts @@ -43,11 +43,13 @@ export const sendSvixEvent = async ({ eventType, data, payloadFields, + idempotencyKey, }: { ctx: AutumnContext; eventType: string; data: unknown; payloadFields?: { id?: string; occurred_at?: number }; + idempotencyKey?: string; }) => { if (!process.env.SVIX_API_KEY) return; @@ -60,14 +62,18 @@ export const sendSvixEvent = async ({ const appId = getSvixAppId({ org, env }); if (!appId) return null; - return await svix.message.create(appId, { - eventType, - payload: { - type: eventType, - ...payloadFields, - data, + return await svix.message.create( + appId, + { + eventType, + payload: { + type: eventType, + ...payloadFields, + data, + }, }, - }); + idempotencyKey ? { idempotencyKey } : undefined, + ); } catch (error) { ctx.logger.error(`[svix] Failed to send ${eventType}: ${error}`); Sentry.captureException(error, { diff --git a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts index dce5e8f7f..cfc9be1d3 100644 --- a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts +++ b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts @@ -139,9 +139,22 @@ const processAlerts = async ({ const customerId = newFullCus.id || newFullCus.internal_id; + const minuteBucket = Math.floor(Date.now() / 60_000); + const idempotencyKey = [ + ctx.org.id, + ctx.env, + customerId, + entityId ?? "_", + feature.id, + alert.threshold_type, + alert.threshold, + minuteBucket, + ].join(":"); + await sendSvixEvent({ ctx, eventType: WebhookEventType.BalancesUsageAlertTriggered, + idempotencyKey, data: { customer_id: customerId, feature_id: feature.id, diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alerts-race-condition.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alerts-race-condition.test.ts new file mode 100644 index 000000000..5009974b4 --- /dev/null +++ b/server/tests/integration/balances/track/usage-alerts/usage-alerts-race-condition.test.ts @@ -0,0 +1,131 @@ +/** + * Replication test for duplicate balances.usage_alert_triggered webhooks + * fired by concurrent /v1/track requests crossing the same threshold. + * + * Expectation: within 2 minutes, exactly 2 matching webhook events arrive. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { LimitedItem } from "@autumn/shared"; +import { setCustomerUsageAlerts } from "@tests/integration/balances/utils/usage-alert-utils/customerUsageAlertUtils.js"; +import { + getPlayHistory, + getTestSvixAppId, + parseEventBody, + setupWebhookTest, + type WebhookTestSetup, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; + +type BalancesUsageAlertTriggeredPayload = { + type: string; + data: { + customer_id: string; + feature_id: string; + usage_alert: { + threshold: number; + threshold_type: string; + }; + }; +}; + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["balances.usage_alert_triggered"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test(`${chalk.yellowBright("usage-alert race: concurrent tracks fire two webhooks crossing the same threshold")}`, async () => { + const creditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + }) as LimitedItem; + + const product = constructProduct({ + type: "pro", + isDefault: false, + items: [creditsItem], + id: "ua-race-condition-pro", + }); + + const threshold = 20; + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "usage-alert-race-condition", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [s.attach({ productId: product.id })], + }); + + await setCustomerUsageAlerts({ + autumn: autumnV2_1, + customerId, + usageAlerts: [ + { + feature_id: TestFeature.Credits, + threshold, + threshold_type: "remaining", + enabled: true, + }, + ], + }); + + // Stage balance just above the threshold so two concurrent small deductions + // can both observe the pre-crossing snapshot and both fire the alert. + // action1 credit_cost = 0.2 → 395 units = 79 credits used → remaining = 21. + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 395, + }); + + // Worst-case stress: many concurrent tracks crossing the SAME threshold. + // Each value is tiny (0.5–1.0 credits) so every track on its own would cross + // remaining=21 → below 20, meaning every handler would naively fire the alert. + // Going negative on remaining is fine for this test — only the FIRST crossing + // of the threshold should fire; subsequent reads see oldRemaining<20 already. + const BURST_SIZE = 500; + await Promise.all( + Array.from({ length: BURST_SIZE }, (_, i) => + autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 0.5 + (i % 50) * 0.01, + }), + ), + ); + + // Wait long enough for any duplicate webhooks to surface, then assert exactly one. + const SETTLE_MS = 30_000; + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + + const history = await getPlayHistory({ token: playToken }); + const matching = history.data + .map((event) => parseEventBody(event)) + .filter( + (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.feature_id === TestFeature.Credits && + payload.data?.usage_alert?.threshold === threshold, + ); + + expect(matching.length).toBe(1); +}, 150_000); diff --git a/server/tests/references/getCustomerV1Response.json b/server/tests/references/getCustomerV1Response.json index 99c09a4c0..ceefe6ab3 100644 --- a/server/tests/references/getCustomerV1Response.json +++ b/server/tests/references/getCustomerV1Response.json @@ -1,10 +1,10 @@ { "id": "get-cus-multi-ent-v2", - "created_at": 1777974355227, + "created_at": 1778167866112, "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", "fingerprint": null, - "stripe_id": "cus_USajNwvM0pax2M", + "stripe_id": "cus_UTQkXDkzlY2hGh", "env": "sandbox", "metadata": {}, "send_email_receipts": false, @@ -15,12 +15,12 @@ "group": "get-cus-multi-ent-v2", "status": "active", "canceled_at": null, - "started_at": 1777974354000, + "started_at": 1778167865000, "is_default": false, "is_add_on": false, "version": 1, - "current_period_start": 1777974354000, - "current_period_end": 1780652754000, + "current_period_start": 1778167865000, + "current_period_end": 1780846265000, "items": [ { "type": "price", @@ -83,7 +83,7 @@ "group": "get-cus-multi-ent-v2", "status": "active", "canceled_at": null, - "started_at": 1777974379436, + "started_at": 1778167888784, "is_default": false, "is_add_on": false, "version": 1, @@ -104,7 +104,7 @@ "balance": 90, "usage": 10, "included_usage": 100, - "next_reset_at": 1780652754000, + "next_reset_at": 1780846265000, "overage_allowed": false, "breakdown": [ { @@ -113,7 +113,7 @@ "balance": 90, "usage": 10, "included_usage": 100, - "next_reset_at": 1780652754000, + "next_reset_at": 1780846265000, "expires_at": null, "overage_allowed": false } @@ -173,12 +173,12 @@ "product_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", + "stripe_id": "in_1TUTtJIidku0FvdnavizBNAV", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1777974354000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" + "created_at": 1778167865000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8" } ] } \ No newline at end of file diff --git a/server/tests/references/getCustomerV2_1Response.json b/server/tests/references/getCustomerV2_1Response.json index cdc2578af..1c88d053c 100644 --- a/server/tests/references/getCustomerV2_1Response.json +++ b/server/tests/references/getCustomerV2_1Response.json @@ -2,16 +2,16 @@ "id": "get-cus-multi-ent-v2", "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", - "created_at": 1777974355227, + "created_at": 1778167866112, "fingerprint": null, - "stripe_id": "cus_USajNwvM0pax2M", + "stripe_id": "cus_UTQkXDkzlY2hGh", "env": "sandbox", "metadata": {}, "send_email_receipts": false, "billing_controls": {}, "subscriptions": [ { - "id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P", + "id": "cus_prod_3DOzO3SlpNyeQx2sJ8ggOp0BnwJ", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -20,13 +20,13 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1777974354000, - "current_period_start": 1777974354000, - "current_period_end": 1780652754000, + "started_at": 1778167865000, + "current_period_start": 1778167865000, + "current_period_end": 1780846265000, "quantity": 1 }, { - "id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp", + "id": "cus_prod_3DOzQdCIlhWL0ZqgYFizxtzScAy", "plan_id": "ent-prod_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -35,7 +35,7 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1777974379436, + "started_at": 1778167888784, "current_period_start": null, "current_period_end": null, "quantity": 1 @@ -52,11 +52,11 @@ "unlimited": false, "overage_allowed": false, "max_purchase": null, - "next_reset_at": 1780652754000, + "next_reset_at": 1780846265000, "breakdown": [ { "object": "balance_breakdown", - "id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue", + "id": "cus_ent_3DOzO7SC9PenACcAtXDG1wBJhch", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "included_grant": 100, "prepaid_grant": 0, @@ -65,7 +65,7 @@ "unlimited": false, "reset": { "interval": "month", - "resets_at": 1780652754000 + "resets_at": 1780846265000 }, "price": null, "expires_at": null, @@ -89,24 +89,30 @@ "flags": { "dashboard": { "object": "flag", - "id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag", + "id": "cus_ent_3DOzO6uL1W9AZ7kC07wdNPOUKxc", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "expires_at": null, "feature_id": "dashboard" } }, "config": {}, + "processors": { + "stripe": { + "id": "cus_UTQkXDkzlY2hGh" + } + }, "invoices": [ { "plan_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", + "stripe_id": "in_1TUTtJIidku0FvdnavizBNAV", + "processor_type": "stripe", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1777974354000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" + "created_at": 1778167865000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8" } ] } \ No newline at end of file diff --git a/server/tests/references/getCustomerV2_2Response.json b/server/tests/references/getCustomerV2_2Response.json index cdc2578af..1c88d053c 100644 --- a/server/tests/references/getCustomerV2_2Response.json +++ b/server/tests/references/getCustomerV2_2Response.json @@ -2,16 +2,16 @@ "id": "get-cus-multi-ent-v2", "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", - "created_at": 1777974355227, + "created_at": 1778167866112, "fingerprint": null, - "stripe_id": "cus_USajNwvM0pax2M", + "stripe_id": "cus_UTQkXDkzlY2hGh", "env": "sandbox", "metadata": {}, "send_email_receipts": false, "billing_controls": {}, "subscriptions": [ { - "id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P", + "id": "cus_prod_3DOzO3SlpNyeQx2sJ8ggOp0BnwJ", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -20,13 +20,13 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1777974354000, - "current_period_start": 1777974354000, - "current_period_end": 1780652754000, + "started_at": 1778167865000, + "current_period_start": 1778167865000, + "current_period_end": 1780846265000, "quantity": 1 }, { - "id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp", + "id": "cus_prod_3DOzQdCIlhWL0ZqgYFizxtzScAy", "plan_id": "ent-prod_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -35,7 +35,7 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1777974379436, + "started_at": 1778167888784, "current_period_start": null, "current_period_end": null, "quantity": 1 @@ -52,11 +52,11 @@ "unlimited": false, "overage_allowed": false, "max_purchase": null, - "next_reset_at": 1780652754000, + "next_reset_at": 1780846265000, "breakdown": [ { "object": "balance_breakdown", - "id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue", + "id": "cus_ent_3DOzO7SC9PenACcAtXDG1wBJhch", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "included_grant": 100, "prepaid_grant": 0, @@ -65,7 +65,7 @@ "unlimited": false, "reset": { "interval": "month", - "resets_at": 1780652754000 + "resets_at": 1780846265000 }, "price": null, "expires_at": null, @@ -89,24 +89,30 @@ "flags": { "dashboard": { "object": "flag", - "id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag", + "id": "cus_ent_3DOzO6uL1W9AZ7kC07wdNPOUKxc", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "expires_at": null, "feature_id": "dashboard" } }, "config": {}, + "processors": { + "stripe": { + "id": "cus_UTQkXDkzlY2hGh" + } + }, "invoices": [ { "plan_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", + "stripe_id": "in_1TUTtJIidku0FvdnavizBNAV", + "processor_type": "stripe", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1777974354000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" + "created_at": 1778167865000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8" } ] } \ No newline at end of file diff --git a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts index b8b97b431..b989d31b2 100644 --- a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts +++ b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { type AggregatedFeatureBalanceSchema, AppEnv, + type FullCustomer, + FullCustomerSchema, ProcessorType, ProductSchema, type SubjectBalance, @@ -648,6 +650,276 @@ describe("sanitizeCachedFullSubject — processor_type (Option A walker behavior }); expect(wire.processor_type).toBe(ProcessorType.Stripe); }); + + test("end-to-end pre-update rollover: cache JSON missing field → JSON.parse → walker → processInvoice → wire stripe", async () => { + // Simulates the deploy-boundary rollover scenario: + // 1. An invoice was cached BEFORE this Phase shipped (no + // `processor_type` key in the JSON). + // 2. After deploy, `getCachedFullSubject` reads that JSON, parses + // it, and sanitizes via the walker. + // 3. `processInvoice` then maps the sanitized row to the V5 wire + // shape. + // The wire MUST emit `processor_type === "stripe"` for the row to + // remain valid in V5 consumers. Any regression in the walker's + // ZodDefault handling OR the consumer-side `??` mask trips this test. + const preUpdateInvoice = buildInvoice(); + delete (preUpdateInvoice as Record).processor_type; + + const cachedJson = JSON.stringify({ + ...(buildCachedFullSubject() as Record), + invoices: [preUpdateInvoice], + }); + + const deserialized = JSON.parse(cachedJson) as Record; + expect( + (deserialized.invoices as Record[])[0] + .processor_type, + ).toBeUndefined(); + + const sanitized = sanitizeCachedFullSubject({ + cachedFullSubject: deserialized as unknown as CachedFullSubject, + }); + expect(sanitized.invoices[0].processor_type).toBe(ProcessorType.Stripe); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ invoice: sanitized.invoices[0] }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + expect(wire.stripe_id).toBe("in_proc"); + }); + + test("end-to-end post-update revenuecat row: cache JSON with revenuecat → roundtrip → wire revenuecat", async () => { + // Mirror of the rollover test for the post-deploy path: an RC invoice + // is written with `processor_type: "revenuecat"`, cached as JSON, + // then read back. The wire MUST surface "revenuecat" intact (no + // over-correction to "stripe" by the walker, the consumer mask, or + // processInvoice). + const postUpdateInvoice = buildInvoice({ + processor_type: ProcessorType.RevenueCat, + stripe_id: "rc_txn_42", + }); + + const cachedJson = JSON.stringify({ + ...(buildCachedFullSubject() as Record), + invoices: [postUpdateInvoice], + }); + + const deserialized = JSON.parse(cachedJson) as Record; + expect( + (deserialized.invoices as Record[])[0] + .processor_type, + ).toBe(ProcessorType.RevenueCat); + + const sanitized = sanitizeCachedFullSubject({ + cachedFullSubject: deserialized as unknown as CachedFullSubject, + }); + expect(sanitized.invoices[0].processor_type).toBe( + ProcessorType.RevenueCat, + ); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ invoice: sanitized.invoices[0] }); + expect(wire.processor_type).toBe(ProcessorType.RevenueCat); + expect(wire.stripe_id).toBe("rc_txn_42"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// processor_type — FullCustomer (v1) cache rollover +// +// The FullCustomer cache (`getCachedFullCustomer`) uses the cacheUtils walker +// at `server/src/utils/cacheUtils/normalizeFromSchema.ts`, NOT the FullSubject +// sanitize walker. The cacheUtils walker has a known asymmetry: it does not +// apply leaf-level ZodDefault for primitives (documented limitation; the +// "ZodDefault does NOT fire on primitive leaves in cacheUtils walker" test +// in the core walker block proves this). For pre-update cached invoices +// missing `processor_type`, the walker leaves the field undefined and the +// consumer-side `?? ProcessorType.Stripe` mask in `processInvoice` is what +// makes the rollover safe. +// +// These tests exercise the v1 cache path end-to-end through JSON roundtrip +// + cacheUtils walker + processInvoice, mirroring the FullSubject (v2) +// rollover tests above. +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("FullCustomer v1 cache — processor_type rollover", () => { + const buildFullCustomer = ( + overrides: Record = {}, + ): Record => ({ + internal_id: "cus_int_v1", + org_id: "org_v1", + env: AppEnv.Live, + created_at: 1, + id: "cus_v1", + name: null, + email: null, + fingerprint: null, + processor: null, + processors: {}, + metadata: {}, + send_email_receipts: false, + auto_topups: null, + spend_limits: null, + usage_alerts: null, + overage_allowed: null, + config: null, + customer_products: [], + entities: [], + extra_customer_entitlements: [], + invoices: [], + ...overrides, + }); + + const buildInvoice = ( + overrides: Record = {}, + ): Record => ({ + id: "inv_v1", + created_at: 1, + internal_customer_id: "cus_int_v1", + internal_entity_id: null, + product_ids: [], + internal_product_ids: [], + stripe_id: "in_v1", + status: "paid", + hosted_invoice_url: null, + total: 100, + amount_paid: 100, + refunded_amount: 0, + currency: "usd", + discounts: [], + items: [], + ...overrides, + }); + + test("pre-update rollover: cache JSON missing processor_type → cacheUtils walker → processInvoice → wire stripe", async () => { + // Pre-deploy FullCustomer cache entry: invoice has no `processor_type` + // key. The cacheUtils walker does NOT fill the default (orthogonal + // bug); it leaves the field undefined. The consumer mask in + // processInvoice converts undefined → ProcessorType.Stripe on the + // wire. End-to-end safety relies on this two-layer behavior. + const preUpdateInvoice = buildInvoice(); + delete (preUpdateInvoice as Record).processor_type; + + const cachedJson = JSON.stringify( + buildFullCustomer({ invoices: [preUpdateInvoice] }), + ); + const deserialized = JSON.parse(cachedJson) as Record; + expect( + (deserialized.invoices as Record[])[0] + .processor_type, + ).toBeUndefined(); + + const fullCustomer = normalizeFromSchemaCacheUtils({ + schema: FullCustomerSchema as unknown as z.ZodTypeAny, + data: deserialized, + }); + + expect( + (fullCustomer.invoices ?? [])[0]?.processor_type, + ).toBeUndefined(); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ + invoice: (fullCustomer.invoices ?? [])[0]!, + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + expect(wire.stripe_id).toBe("in_v1"); + }); + + test("explicit null processor_type → cacheUtils walker passthrough → processInvoice → wire stripe", async () => { + // Post-deploy DB row with NULL processor_type cached as JSON. The + // cacheUtils walker passes null through unchanged. Consumer mask + // converts null → ProcessorType.Stripe. + const cachedJson = JSON.stringify( + buildFullCustomer({ + invoices: [buildInvoice({ processor_type: null })], + }), + ); + const deserialized = JSON.parse(cachedJson) as Record; + + const fullCustomer = normalizeFromSchemaCacheUtils({ + schema: FullCustomerSchema as unknown as z.ZodTypeAny, + data: deserialized, + }); + expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBeNull(); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ + invoice: (fullCustomer.invoices ?? [])[0]!, + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + }); + + test("post-update revenuecat row → cacheUtils walker preserves → wire revenuecat", async () => { + // RC invoice cached after Phase 3 deploys. Walker passes the value + // through unchanged; consumer mask is a no-op for defined values. + const cachedJson = JSON.stringify( + buildFullCustomer({ + invoices: [ + buildInvoice({ + processor_type: ProcessorType.RevenueCat, + stripe_id: "rc_txn_v1", + }), + ], + }), + ); + const deserialized = JSON.parse(cachedJson) as Record; + + const fullCustomer = normalizeFromSchemaCacheUtils({ + schema: FullCustomerSchema as unknown as z.ZodTypeAny, + data: deserialized, + }); + expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBe( + ProcessorType.RevenueCat, + ); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ + invoice: (fullCustomer.invoices ?? [])[0]!, + }); + expect(wire.processor_type).toBe(ProcessorType.RevenueCat); + expect(wire.stripe_id).toBe("rc_txn_v1"); + }); + + test("post-update stripe row with explicit processor_type → cacheUtils walker preserves → wire stripe", async () => { + // Post-Phase-2 stripe writes set processor_type explicitly via + // initInvoiceFromStripe. New cached entries have the field present. + // Walker preserves; consumer mask is a no-op. Sanity check that + // nothing over-corrects. + const cachedJson = JSON.stringify( + buildFullCustomer({ + invoices: [ + buildInvoice({ processor_type: ProcessorType.Stripe }), + ], + }), + ); + const deserialized = JSON.parse(cachedJson) as Record; + + const fullCustomer = normalizeFromSchemaCacheUtils({ + schema: FullCustomerSchema as unknown as z.ZodTypeAny, + data: deserialized, + }); + expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBe( + ProcessorType.Stripe, + ); + + const { processInvoice } = await import( + "@/internal/invoices/InvoiceService.js" + ); + const wire = processInvoice({ + invoice: (fullCustomer.invoices ?? [])[0]!, + }); + expect(wire.processor_type).toBe(ProcessorType.Stripe); + }); }); describe("sanitizeCachedAggregatedFeatureBalance", () => {