From 980d7cd7370da2c4df2a67c161d0442dd50962a2 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:15:22 +0100 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20allow=20cross-proces?= =?UTF-8?q?sor=20one-off=20product=20purchases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../misc/resolveRevenuecatResources.ts | 11 +- .../attach/attachUtils/handleAttachErrors.ts | 18 + .../handleMultiAttachErrors.ts | 5 + .../revenuecat-cross-processor-oneoff.test.ts | 345 ++++++++++++++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts diff --git a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts index b44968089..c164c1ffb 100644 --- a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts +++ b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts @@ -10,6 +10,7 @@ import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext"; import { CusService } from "@/internal/customers/CusService"; import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js"; +import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; import { ProductService } from "@/internal/products/ProductService"; import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer"; @@ -69,8 +70,16 @@ export const resolveRevenuecatResources = async ({ }), ]); - // If the customer has a product from a different processor than RevenueCat and it has no subscriptions, throw an error + // If the customer has a product from a different processor than RevenueCat and it has no subscriptions, throw an error. + // + // Exception: true one-off purchases (no recurring intervals) are safe to mix + // across processors because they create a parallel cus_product without + // replacing the customer's existing subscription. This lets a Stripe-subscribed + // customer buy a one-off pack via RevenueCat (and vice versa). + const incomingIsOneOff = pricesOnlyOneOff(product.prices); + if ( + !incomingIsOneOff && customer.customer_products.some( (cp) => cp.processor?.type !== ProcessorType.RevenueCat && diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 07062585b..e1ec72a54 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -16,6 +16,7 @@ import { getEntOptions, getPriceEntitlement, priceIsOneOffAndTiered, + pricesOnlyOneOff, } from "@/internal/products/prices/priceUtils.js"; import { notNullish, nullOrUndefined } from "@/utils/genUtils.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; @@ -159,9 +160,26 @@ export const handleCustomPaymentMethodErrors = ({ export const handleExternalPSPErrors = ({ attachParams, + strict = false, }: { attachParams: AttachParams; + /** + * When true, never bypass the cross-processor guard. Use for MultiAttach + * where the customer's whole subscription state could change. + */ + strict?: boolean; }) => { + // Safe path: a single-product attach for a true one-off product can mix + // across processors. One-offs create a parallel cus_product and never + // replace an existing subscription, so a customer with an active RC sub + // can still buy a Stripe-billed top-up (and vice versa). + const oneOffEscape = + !strict && + attachParams.products.length === 1 && + pricesOnlyOneOff(attachParams.prices); + + if (oneOffEscape) return; + if ( attachParams.customer.customer_products.some( (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts index dc9d124a1..b36563c66 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts @@ -8,6 +8,7 @@ import { } from "@autumn/shared"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import RecaseError from "@/utils/errorUtils.js"; +import { handleExternalPSPErrors } from "../handleAttachErrors.js"; export const handleMultiAttachErrors = async ({ attachParams, @@ -20,6 +21,10 @@ export const handleMultiAttachErrors = async ({ }) => { const { products, prices, productsList } = attachParams; + // MultiAttach must stay fully blocked for cross-processor customers — even + // for one-off products, because the batch may include recurring main products. + handleExternalPSPErrors({ attachParams, strict: true }); + const usagePrice = prices.find((p: Price) => isUsagePrice({ price: p })); // 1. Don't support usage prices just yet... diff --git a/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts b/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts new file mode 100644 index 000000000..7d4b1276d --- /dev/null +++ b/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts @@ -0,0 +1,345 @@ +/** + * TDD: Allow one-off purchases across processors when the other processor + * already manages an active subscription for the customer. + * + * Today we strictly block any cross-processor activity to avoid edge cases + * with mixed subscriptions. One-offs are safe because they don't replace the + * existing subscription — they create a parallel cus_product. + * + * Red-failure mode (current behavior): + * Test 1 — Stripe sub + RC one-off top-up: + * resolveRevenuecatResources() throws "Customer already has a product from + * a different processor than RevenueCat." → webhook returns 500. + * + * Test 2 — RC sub + Stripe one-off top-up: + * handleExternalPSPErrors() throws "This customer is billed outside of + * Stripe..." on autumnV1.attach(). + * + * Test 3 — Negative guards: + * Recurring product cross-processor must STILL be rejected after the fix. + * + * Green-success criteria (after fix): + * Tests 1 & 2 succeed; both products end up active on the customer. + * Test 3 sub-cases continue to throw the cross-processor error. + */ + +import { expect, test } from "bun:test"; +import { AppEnv, customers } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { encryptData } from "@/utils/encryptUtils"; +import { + expectWebhookSuccess, + RevenueCatWebhookClient, +} from "./utils/revenue-cat-webhook-client"; + +const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_xproc"; + +const setupRevenueCatOrg = async () => { + if ( + ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== + RC_WEBHOOK_SECRET + ) { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + processor_configs: { + ...ctx.org.processor_configs, + revenuecat: { + api_key: encryptData("mock_rc_api_key_live"), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + project_id: "mock_project_live", + sandbox_project_id: "mock_project_sandbox", + webhook_secret: RC_WEBHOOK_SECRET, + sandbox_webhook_secret: RC_WEBHOOK_SECRET, + }, + }, + }, + }); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Stripe sub + RC one-off top-up +// +// Customer has an active Stripe subscription (proMonthly attached via Stripe). +// They make a one-off in-app top-up via RevenueCat. +// Expected (after fix): RC webhook succeeds; both products are active. +// Currently (red): resolver guard rejects → 500 from webhook. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("revenuecat cross-processor 1: stripe sub + rc one-off top-up")}`, + async () => { + const customerId = "rc-xproc-1"; + + // RevenueCat product ID for the in-app top-up + const RC_TOP_UP_ID = "com.app.rc_xproc_top_up_pack"; + + // Autumn products + const proMonthly = products.pro({ + id: "rc-xproc-pro-monthly", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + // True one-off add-on: pricesOnlyOneOff(prices) === true + const topUpPack = products.oneOff({ + id: "rc-xproc-top-up-pack", + items: [items.lifetimeMessages({ includedUsage: 100 })], + isAddOn: true, + }); + + // Setup org with RevenueCat config + await setupRevenueCatOrg(); + + // Initialize scenario: customer with payment method, Stripe attaches proMonthly + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proMonthly, topUpPack] }), + ], + actions: [s.attach({ productId: proMonthly.id })], + }); + + // Map RC product → Autumn one-off top-up product + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: topUpPack.id, + revenuecat_product_ids: [RC_TOP_UP_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Sanity check: customer exists and has the Stripe sub + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + + // Action: RC fires NON_RENEWING_PURCHASE for the mapped one-off product + const result = await rcClient.nonRenewingPurchase({ + productId: RC_TOP_UP_ID, + appUserId: customerId, + originalTransactionId: "rc_xproc_1_topup_tx_001", + }); + + // PRIMARY ASSERTION (red here): webhook should succeed + expectWebhookSuccess(result); + + // State assertion: customer now has both products active + const customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(2); + const productIds = customer.products + .map((p: { id: string }) => p.id) + .sort(); + expect(productIds).toEqual([proMonthly.id, topUpPack.id].sort()); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: RC sub + Stripe one-off top-up +// +// Customer has an active RevenueCat subscription (proMonthly via RC webhook). +// They buy a one-off pack via Stripe (autumnV1.attach). +// Expected (after fix): attach succeeds; both products are active. +// Currently (red): handleExternalPSPErrors throws. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("revenuecat cross-processor 2: rc sub + stripe one-off top-up")}`, + async () => { + const customerId = "rc-xproc-2"; + + const RC_PRO_MONTHLY_ID = "com.app.rc_xproc_pro_monthly"; + + // RC-managed recurring product + const rcProMonthly = products.pro({ + id: "rc-xproc-2-pro-monthly", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + // Stripe-side true one-off add-on + const webTopUp = products.oneOff({ + id: "rc-xproc-2-web-top-up", + items: [items.lifetimeMessages({ includedUsage: 100 })], + isAddOn: true, + }); + + await setupRevenueCatOrg(); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [rcProMonthly, webTopUp] }), + ], + actions: [], + }); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: rcProMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Step 1: RC initial purchase puts customer on the RC-managed subscription + const rcResult = await rcClient.initialPurchase({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc_xproc_2_tx_001", + }); + expectWebhookSuccess(rcResult); + + // Confirm pre-state: 1 product active (RC sub) + let customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(rcProMonthly.id); + + // PRIMARY ACTION (red here): attach the Stripe one-off top-up + await autumnV1.attach({ + customer_id: customerId, + product_id: webTopUp.id, + }); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(2); + const productIds = customer.products + .map((p: { id: string }) => p.id) + .sort(); + expect(productIds).toEqual([rcProMonthly.id, webTopUp.id].sort()); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Negative guards — recurring cross-processor must STILL be blocked +// +// Sub-case A: Stripe-subscribed customer → RC INITIAL_PURCHASE for a recurring +// RC-mapped Autumn product. Webhook must fail (non-200). +// Sub-case B: RC-subscribed customer → autumnV1.attach of a recurring Stripe +// product. Attach must throw the cross-processor error. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("revenuecat cross-processor 3: negative guards (recurring still blocked)")}`, + async () => { + // ─── Sub-case A ──────────────────────────────────────────────────────────── + const customerIdA = "rc-xproc-3a"; + const RC_RECURRING_ID = "com.app.rc_xproc_3a_recurring"; + + const stripeProMonthly = products.pro({ + id: "rc-xproc-3a-stripe-pro", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + const rcRecurring = products.pro({ + id: "rc-xproc-3a-rc-recurring", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + await setupRevenueCatOrg(); + + await initScenario({ + customerId: customerIdA, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [stripeProMonthly, rcRecurring] }), + ], + actions: [s.attach({ productId: stripeProMonthly.id })], + }); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: rcRecurring.id, + revenuecat_product_ids: [RC_RECURRING_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // RC tries to start a recurring sub on a Stripe-subscribed customer → must fail + const recurringResult = await rcClient.initialPurchase({ + productId: RC_RECURRING_ID, + appUserId: customerIdA, + originalTransactionId: "rc_xproc_3a_tx_001", + }); + expect(recurringResult.response.status).not.toBe(200); + + // ─── Sub-case B ──────────────────────────────────────────────────────────── + const customerIdB = "rc-xproc-3b"; + const RC_PRO_ID_B = "com.app.rc_xproc_3b_pro"; + + const rcProB = products.pro({ + id: "rc-xproc-3b-rc-pro", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + // Recurring add-on (NOT a one-off): cross-processor attach should still throw + const stripeRecurringAddOn = products.recurringAddOn({ + id: "rc-xproc-3b-recurring-addon", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + + const { autumnV1 } = await initScenario({ + customerId: customerIdB, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [rcProB, stripeRecurringAddOn] }), + ], + actions: [], + }); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: rcProB.id, + revenuecat_product_ids: [RC_PRO_ID_B], + }, + }); + + const rcResult = await rcClient.initialPurchase({ + productId: RC_PRO_ID_B, + appUserId: customerIdB, + originalTransactionId: "rc_xproc_3b_tx_001", + }); + expectWebhookSuccess(rcResult); + + // Now attempt to attach a recurring Stripe add-on → must throw + await expect( + autumnV1.attach({ + customer_id: customerIdB, + product_id: stripeRecurringAddOn.id, + }), + ).rejects.toThrow(/Stripe|RevenueCat|external|managed/i); + }, +); From 48b2c2fb95bd0289614f1651aeea4829fccd9538 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:45:00 +0100 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=F0=9F=90=9B=20broken=20test=20for?= =?UTF-8?q?=20revcat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/revenuecatWebhooks.test.ts | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts b/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts index 225c3a708..c6eeb9f68 100644 --- a/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts +++ b/server/tests/integration/external-psps/utils/revenuecatWebhooks.test.ts @@ -4,11 +4,12 @@ import { AppEnv, CusProductStatus, customers, + revenuecatMappings, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; -import { eq } from "drizzle-orm"; +import { and, arrayOverlaps, eq } from "drizzle-orm"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; @@ -176,13 +177,50 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { webhookSecret: RC_WEBHOOK_SECRET, }); - // 2-4. Create products, mappings, and customer concurrently + // IMPORTANT: clean up stale RC mappings before re-upserting. + // + // `addPrefixToProducts` was changed in Jan from prefix-style + // (`${prefix}_${id}`) to suffix-style (`${id}_${prefix}`). Old runs left + // rows in `revenuecat_mappings` keyed by the prefix-style autumn_product_id + // (e.g. `rc1_rc1-pro-monthly`). The mapping table PK includes + // `autumn_product_id`, so a fresh upsert under the new ID does NOT + // overwrite the stale row. The resolver's `arrayContains` lookup can then + // return the stale row, causing `ProductNotFoundError`. + // + // Clear by `revenuecat_product_ids` overlap so the cleanup is independent + // of whatever stale autumn_product_id format was used previously. + await ctx.db + .delete(revenuecatMappings) + .where( + and( + eq(revenuecatMappings.org_id, ctx.org.id), + eq(revenuecatMappings.env, AppEnv.Sandbox), + arrayOverlaps(revenuecatMappings.revenuecat_product_ids, [ + RC_PRO_MONTHLY_ID, + RC_PRO_YEARLY_ID, + RC_ADD_ON_ID, + ]), + ), + ); + + // Create products + customer concurrently. Products must finish before + // we read their (post-prefix) IDs for the mappings below. await Promise.all([ initProductsV0({ ctx, products: [proMonthly, proYearly, addOnPack], prefix: testCase, }), + initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }), + ]); + + // Now that initProductsV0 has mutated the product IDs (suffix-style), + // upsert mappings with the final, correct autumn_product_id values. + await Promise.all([ RCMappingService.upsert({ db: ctx.db, data: { @@ -210,11 +248,6 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { revenuecat_product_ids: [RC_PRO_YEARLY_ID], }, }), - initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }), ]); const dbCustomer = await ctx.db.query.customers.findFirst({ From 7f365ba1a88f3a05a634fc1347ca714663fcaf95 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:41:22 +0100 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=F0=9F=90=9B=20circular=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attach/attachUtils/handleAttachErrors.ts | 38 +---------------- .../handleCheckoutErrors.ts | 6 +-- .../handleExternalPSPErrors.ts | 41 +++++++++++++++++++ .../handleMultiAttachErrors.ts | 2 +- 4 files changed, 45 insertions(+), 42 deletions(-) create mode 100644 server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleExternalPSPErrors.ts diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index e1ec72a54..82dbe90df 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -3,9 +3,7 @@ import { AttachBranch, type AttachConfig, BillingType, - cusProductToProcessorType, ErrCode, - ProcessorType, RecaseError, TierBehavior, type UsagePriceConfig, @@ -16,11 +14,11 @@ import { getEntOptions, getPriceEntitlement, priceIsOneOffAndTiered, - pricesOnlyOneOff, } from "@/internal/products/prices/priceUtils.js"; import { notNullish, nullOrUndefined } from "@/utils/genUtils.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; import type { AttachFlags } from "../models/AttachFlags.js"; +import { handleExternalPSPErrors } from "./handleAttachErrors/handleExternalPSPErrors.js"; import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js"; const handleNonCheckoutErrors = ({ @@ -158,40 +156,6 @@ export const handleCustomPaymentMethodErrors = ({ } }; -export const handleExternalPSPErrors = ({ - attachParams, - strict = false, -}: { - attachParams: AttachParams; - /** - * When true, never bypass the cross-processor guard. Use for MultiAttach - * where the customer's whole subscription state could change. - */ - strict?: boolean; -}) => { - // Safe path: a single-product attach for a true one-off product can mix - // across processors. One-offs create a parallel cus_product and never - // replace an existing subscription, so a customer with an active RC sub - // can still buy a Stripe-billed top-up (and vice versa). - const oneOffEscape = - !strict && - attachParams.products.length === 1 && - pricesOnlyOneOff(attachParams.prices); - - if (oneOffEscape) return; - - if ( - attachParams.customer.customer_products.some( - (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, - ) - ) { - throw new RecaseError({ - message: - "This customer is billed outside of Stripe, please use the origin platform to manage their billing.", - }); - } -}; - export const handlePrepaidVolumeErrors = ({ attachParams, }: { diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts index ae9b8bed2..fc5cc18da 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts @@ -1,9 +1,7 @@ import type { AttachBranch } from "@autumn/shared"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { - handleCustomPaymentMethodErrors, - handleExternalPSPErrors, -} from "../handleAttachErrors.js"; +import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js"; +import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js"; export const handleCheckoutErrors = ({ attachParams, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleExternalPSPErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleExternalPSPErrors.ts new file mode 100644 index 000000000..790eb5014 --- /dev/null +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleExternalPSPErrors.ts @@ -0,0 +1,41 @@ +import { + cusProductToProcessorType, + ProcessorType, + RecaseError, +} from "@autumn/shared"; +import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; + +export const handleExternalPSPErrors = ({ + attachParams, + strict = false, +}: { + attachParams: AttachParams; + /** + * When true, never bypass the cross-processor guard. Use for MultiAttach + * where the customer's whole subscription state could change. + */ + strict?: boolean; +}) => { + // Safe path: a single-product attach for a true one-off product can mix + // across processors. One-offs create a parallel cus_product and never + // replace an existing subscription, so a customer with an active RC sub + // can still buy a Stripe-billed top-up (and vice versa). + const oneOffEscape = + !strict && + attachParams.products.length === 1 && + pricesOnlyOneOff(attachParams.prices); + + if (oneOffEscape) return; + + if ( + attachParams.customer.customer_products.some( + (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, + ) + ) { + throw new RecaseError({ + message: + "This customer is billed outside of Stripe, please use the origin platform to manage their billing.", + }); + } +}; diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts index b36563c66..d79b9c8c2 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts @@ -8,7 +8,7 @@ import { } from "@autumn/shared"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import RecaseError from "@/utils/errorUtils.js"; -import { handleExternalPSPErrors } from "../handleAttachErrors.js"; +import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js"; export const handleMultiAttachErrors = async ({ attachParams, From 2e54ce32f8370e4cd361c239751d9bef5f6e4f1c Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:25:21 +0100 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=F0=9F=90=9B=20lots=20of=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts index 3074ee36a..bcf9a3de2 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts @@ -64,7 +64,7 @@ export const lazyResetSubjectEntitlements = async ({ const allCustomerEntitlements = fullSubjectToCustomerEntitlements({ fullSubject, - inStatuses: [CusProductStatus.Active], + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], }); const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({ From ba9091d6e9b8f6e5e491d6547b5dd45fa1070131 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:34:52 +0100 Subject: [PATCH 5/9] fix: block recurring add-ons across processors and filter non-Stripe cus products from Stripe sub builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleExternalPSPErrors (V2 attach): mirror PR #1392's V1 semantics — scan all customer_products and bypass only when attachProduct.prices are all one-off via pricesOnlyOneOff. Recurring add-ons stay strictly blocked. - handleAttachV2Errors: pass full customer_products + attachProduct so the gate sees the customer's whole state, not just the upgrade source. - buildStripeSubscriptionItemsUpdate: drop non-Stripe-managed cus products before building recurring item specs. Without this, an RC product's Stripe price would leak into a brand-new Stripe subscription created for an add-on attach (Runable +$25 overcharge bug). - Add filterCustomerProductsByProcessorType helper. - Add unit tests for both layers. --- .../attach/errors/handleAttachV2Errors.ts | 3 +- .../common/errors/handleExternalPSPErrors.ts | 59 +++- .../buildStripeSubscriptionItemsUpdate.ts | 21 +- .../handle-external-psp-errors.spec.ts | 263 ++++++++++++++++++ ...ription-items-update-multi-product.spec.ts | 123 +++++++- .../utils/fixtures/db/customerProducts.ts | 4 + .../filterCustomerProductsByProcessorType.ts | 27 ++ shared/utils/cusProductUtils/index.ts | 1 + 8 files changed, 486 insertions(+), 15 deletions(-) create mode 100644 server/tests/unit/billing/handle-external-psp-errors.spec.ts create mode 100644 shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts index 3bfbeed3f..208eff587 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -34,7 +34,8 @@ export const handleAttachV2Errors = async ({ // 1. External PSP errors (RevenueCat) handleExternalPSPErrors({ - customerProduct: billingContext.currentCustomerProduct, + customerProducts: billingContext.fullCustomer.customer_products, + attachProduct: billingContext.attachProduct, action: "attach", }); diff --git a/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts b/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts index 1a1240ab9..4d819b4d1 100644 --- a/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts +++ b/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts @@ -1,31 +1,72 @@ import { cusProductToProcessorType, type FullCusProduct, + type FullProduct, ProcessorType, RecaseError, } from "@autumn/shared"; +import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; /** - * Validates that we're not trying to modify a customer product managed by an external PSP like RevenueCat. + * Validates that we're not trying to modify (or attach alongside) a customer + * product managed by an external PSP like RevenueCat. + * + * For `update`: validates the specific cusProduct being modified. + * + * For `attach`: scans the customer's existing products. Throws if any are + * managed by a non-Stripe processor — UNLESS the product being attached is a + * true one-off (no recurring prices). True one-off attaches are safe across + * processors because they create a parallel cus_product without replacing + * the customer's existing subscription, and they never spin up a new Stripe + * subscription that could conflict with an RC-managed plan. + * + * Recurring add-ons are NOT exempt — they create a Stripe subscription that + * would coexist with the RC-managed main product, leading to incorrect billing. */ export const handleExternalPSPErrors = ({ customerProduct, + customerProducts, + attachProduct, action, }: { + /** For `update`: the specific customer product being modified. */ customerProduct?: FullCusProduct; + /** For `attach`: all of the customer's current products. */ + customerProducts?: FullCusProduct[]; + /** For `attach`: the product being attached. */ + attachProduct?: FullProduct; action: "attach" | "update"; }) => { - if (!customerProduct) return; + if (action === "update") { + if (!customerProduct) return; - const processorType = cusProductToProcessorType(customerProduct); - if (processorType === ProcessorType.RevenueCat) { - const message = - action === "attach" - ? `Cannot attach because the customer's current product '${customerProduct.product.name}' is managed by RevenueCat.` - : `Cannot update '${customerProduct.product.name}' because it is managed by RevenueCat.`; + const processorType = cusProductToProcessorType(customerProduct); + if (processorType === ProcessorType.RevenueCat) { + throw new RecaseError({ + message: `Cannot update '${customerProduct.product.name}' because it is managed by RevenueCat.`, + }); + } + return; + } + // action === "attach" + if (!customerProducts || customerProducts.length === 0) return; + + // Safe path: a true one-off attach (no recurring prices) can mix across + // processors. One-offs create a parallel cus_product and never spin up a + // recurring Stripe subscription, so a customer with an active RC sub can + // still buy a Stripe-billed top-up. Recurring add-ons take the strict path. + if (attachProduct && pricesOnlyOneOff(attachProduct.prices)) { + return; + } + + const externalCusProduct = customerProducts.find( + (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, + ); + + if (externalCusProduct) { throw new RecaseError({ - message, + message: `Cannot attach because the customer's current product '${externalCusProduct.product.name}' is managed by RevenueCat.`, }); } }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index 7f3a2b7b1..c75a7cf27 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -1,7 +1,9 @@ import type { BillingContext, StripeItemSpec } from "@autumn/shared"; import { filterCustomerProductsByActiveStatuses, + filterCustomerProductsByProcessorType, filterCustomerProductsByStripeSubscriptionId, + ProcessorType, } from "@autumn/shared"; import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; import type Stripe from "stripe"; @@ -94,19 +96,30 @@ export const buildStripeSubscriptionItemsUpdate = ({ stripeSubscriptionId: billingContext.stripeSubscription?.id, }); - // 2. Filter customer products by active statuses - const activeCustomerProducts = filterCustomerProductsByActiveStatuses({ + // 2. Drop customer products managed by a non-Stripe processor (e.g. RevenueCat). + // + // `filterCustomerProductsByStripeSubscriptionId` with `undefined` returns every + // customer product whose `subscription_ids` is empty — and RC-managed cus products + // have empty `subscription_ids`. Without this step, an RC product's Stripe price + // would leak into a brand-new Stripe subscription created for an add-on attach. + const stripeManagedCustomerProducts = filterCustomerProductsByProcessorType({ customerProducts: relatedCustomerProducts, + processorType: ProcessorType.Stripe, }); - // 3. Get recurring subscription item array (doesn't include one-off items) + // 3. Filter customer products by active statuses + const activeCustomerProducts = filterCustomerProductsByActiveStatuses({ + customerProducts: stripeManagedCustomerProducts, + }); + + // 4. Get recurring subscription item array (doesn't include one-off items) const recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({ ctx, billingContext, customerProducts: activeCustomerProducts, }); - // 4. Diff against current subscription items + // 5. Diff against current subscription items return stripeItemSpecsToSubItemsUpdate({ billingContext, stripeItemSpecs: recurringStripeItemSpecs, diff --git a/server/tests/unit/billing/handle-external-psp-errors.spec.ts b/server/tests/unit/billing/handle-external-psp-errors.spec.ts new file mode 100644 index 000000000..b14ebc34f --- /dev/null +++ b/server/tests/unit/billing/handle-external-psp-errors.spec.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for handleExternalPSPErrors (V2 attach + update gate). + * + * Key invariants: + * - On `update`, fail when the targeted cusProduct is RC-managed. + * - On `attach`, scan ALL customer_products for non-Stripe processors. + * - On `attach`, bypass ONLY when attaching a true one-off (every price has + * interval === OneOff). Recurring add-ons take the strict path. + */ + +import { describe, expect, test } from "bun:test"; +import { + BillingInterval, + type FullCusProduct, + type FullProduct, + PriceType, + ProcessorType, + type RecaseError, +} from "@autumn/shared"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { prices as priceFixtures } from "@tests/utils/fixtures/db/prices"; +import { products as productFixtures } from "@tests/utils/fixtures/db/products"; +import chalk from "chalk"; +import { handleExternalPSPErrors } from "@/internal/billing/v2/common/errors/handleExternalPSPErrors"; + +const expectThrows = (fn: () => unknown, messageMatch: string | RegExp) => { + let caught: unknown; + try { + fn(); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + const err = caught as RecaseError; + if (typeof messageMatch === "string") { + expect(err.message).toContain(messageMatch); + } else { + expect(err.message).toMatch(messageMatch); + } +}; + +const buildOneOffProduct = (id: string, isAddOn = true): FullProduct => + productFixtures.createFull({ + id, + name: id, + isAddOn, + prices: [priceFixtures.createOneOff({ id: `pr_${id}` })], + }); + +const buildRecurringProduct = (id: string, isAddOn = false): FullProduct => + productFixtures.createFull({ + id, + name: id, + isAddOn, + prices: [priceFixtures.createFixed({ id: `pr_${id}` })], + }); + +const buildMixedIntervalProduct = (id: string): FullProduct => + productFixtures.createFull({ + id, + name: id, + isAddOn: true, + prices: [ + priceFixtures.createOneOff({ id: `pr_${id}_oneoff` }), + // A recurring price alongside a one-off → not "only one off" + { + id: `pr_${id}_monthly`, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "fixed_cycle", + is_custom: false, + entitlement_id: null, + proration_config: null, + config: { + type: PriceType.Fixed, + amount: 50, + interval: BillingInterval.Month, + stripe_price_id: `stripe_price_${id}_monthly`, + }, + } as FullProduct["prices"][number], + ], + }); + +const buildRcCusProduct = (id = "cus_prod_rc"): FullCusProduct => + customerProducts.create({ + id, + productId: "rc_main", + processorType: ProcessorType.RevenueCat, + subscriptionIds: [], + }); + +const buildStripeCusProduct = (id = "cus_prod_stripe"): FullCusProduct => + customerProducts.create({ + id, + productId: "stripe_main", + processorType: ProcessorType.Stripe, + subscriptionIds: ["sub_xyz"], + }); + +describe( + chalk.yellowBright("handleExternalPSPErrors v2 - update action"), + () => { + test("throws when updating an RC-managed cusProduct", () => { + const cp = buildRcCusProduct(); + expectThrows( + () => + handleExternalPSPErrors({ + customerProduct: cp, + action: "update", + }), + "managed by RevenueCat", + ); + }); + + test("does not throw when updating a Stripe-managed cusProduct", () => { + const cp = buildStripeCusProduct(); + expect(() => + handleExternalPSPErrors({ + customerProduct: cp, + action: "update", + }), + ).not.toThrow(); + }); + + test("does not throw when no cusProduct is provided", () => { + expect(() => + handleExternalPSPErrors({ action: "update" }), + ).not.toThrow(); + }); + }, +); + +describe( + chalk.yellowBright("handleExternalPSPErrors v2 - attach action"), + () => { + test("BYPASS: attaching a one-off product when customer has RC main", () => { + const rc = buildRcCusProduct(); + const oneOff = buildOneOffProduct("topup_25", true); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [rc], + attachProduct: oneOff, + action: "attach", + }), + ).not.toThrow(); + }); + + test("THROWS: attaching a recurring add-on when customer has RC main", () => { + const rc = buildRcCusProduct(); + const recurringAddOn = buildRecurringProduct("recurring_addon", true); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [rc], + attachProduct: recurringAddOn, + action: "attach", + }), + "managed by RevenueCat", + ); + }); + + test("THROWS: attaching a main recurring product when customer has RC main", () => { + const rc = buildRcCusProduct(); + const mainRecurring = buildRecurringProduct("pro_50_monthly", false); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [rc], + attachProduct: mainRecurring, + action: "attach", + }), + "managed by RevenueCat", + ); + }); + + test("THROWS: attaching a product with mixed one-off + recurring prices when customer has RC main", () => { + const rc = buildRcCusProduct(); + const mixed = buildMixedIntervalProduct("mixed"); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [rc], + attachProduct: mixed, + action: "attach", + }), + "managed by RevenueCat", + ); + }); + + test("BYPASS: customer has only Stripe-managed products, attaching a recurring add-on", () => { + const stripe = buildStripeCusProduct(); + const recurringAddOn = buildRecurringProduct("recurring_addon", true); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [stripe], + attachProduct: recurringAddOn, + action: "attach", + }), + ).not.toThrow(); + }); + + test("BYPASS: empty customer_products list", () => { + const oneOff = buildOneOffProduct("topup_25", true); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [], + attachProduct: oneOff, + action: "attach", + }), + ).not.toThrow(); + }); + + test("Mixed customer products: throws on RC even if Stripe is also present, when attaching recurring", () => { + const rc = buildRcCusProduct("cus_prod_rc"); + const stripe = buildStripeCusProduct("cus_prod_stripe"); + const recurringAddOn = buildRecurringProduct("recurring_addon", true); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [stripe, rc], + attachProduct: recurringAddOn, + action: "attach", + }), + "managed by RevenueCat", + ); + }); + + test("Mixed customer products: bypass on RC + Stripe customer when attaching one-off", () => { + const rc = buildRcCusProduct("cus_prod_rc"); + const stripe = buildStripeCusProduct("cus_prod_stripe"); + const oneOff = buildOneOffProduct("topup_25", true); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [stripe, rc], + attachProduct: oneOff, + action: "attach", + }), + ).not.toThrow(); + }); + + test("Defensive: throws when no attachProduct is provided but customer has RC", () => { + const rc = buildRcCusProduct(); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [rc], + action: "attach", + }), + "managed by RevenueCat", + ); + }); + }, +); diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts index 27d9bf12d..00aadb6a3 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts @@ -9,7 +9,7 @@ */ import { describe, expect, test } from "bun:test"; -import { CusProductStatus } from "@autumn/shared"; +import { CusProductStatus, ProcessorType } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions"; @@ -1200,5 +1200,126 @@ describe( expect(result[0].price).toBe("stripe_pro_consumable"); }); }); + + describe(chalk.cyan("Processor Filtering"), () => { + test("Excludes RevenueCat-managed products when attaching a Stripe add-on (no existing subscription)", () => { + // Repro for Runable bug: a customer's main product is managed by + // RevenueCat (subscription_ids: []) and they attach a one-off / + // add-on via Stripe. Without filtering by processor type, the RC + // product's prices would leak into the new Stripe subscription. + const rcMain = createProductWithAllPriceTypes({ + productId: "runable_pro_25_monthly", + productName: "Pro 25 Monthly (RevenueCat)", + customerProductId: "cus_prod_rc_main", + }); + + const stripeAddOn = createProductWithAllPriceTypes({ + productId: "runable_topup_25", + productName: "Topup 25", + customerProductId: "cus_prod_topup", + isAddOn: true, + }); + + const rcMainCustomerProduct = customerProducts.create({ + id: "cus_prod_rc_main", + productId: "runable_pro_25_monthly", + product: rcMain.product, + customerPrices: createCustomerPricesForProduct({ + prices: rcMain.allPrices, + customerProductId: "cus_prod_rc_main", + }), + customerEntitlements: rcMain.allEntitlements, + options: rcMain.allOptions, + status: CusProductStatus.Active, + subscriptionIds: [], // RC products have no Stripe subscription + processorType: ProcessorType.RevenueCat, + }); + + const stripeAddOnCustomerProduct = customerProducts.create({ + id: "cus_prod_topup", + productId: "runable_topup_25", + product: stripeAddOn.product, + customerPrices: createCustomerPricesForProduct({ + prices: stripeAddOn.allPrices, + customerProductId: "cus_prod_topup", + }), + customerEntitlements: stripeAddOn.allEntitlements, + options: stripeAddOn.allOptions, + status: CusProductStatus.Active, + subscriptionIds: [], + // processor defaults to Stripe via cusProductToProcessorType + }); + + const ctx = contexts.create({ features: [] }); + // One-off / add-on attach: no existing Stripe subscription + const billingContext = contexts.createBilling({ + customerProducts: [ + rcMainCustomerProduct, + stripeAddOnCustomerProduct, + ], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + finalCustomerProducts: [ + rcMainCustomerProduct, + stripeAddOnCustomerProduct, + ], + }); + + // No item should reference the RC-managed product's prices + const rcPriceIds = getStripePriceIds(rcMain); + for (const item of result) { + expect(rcPriceIds).not.toContain(item.price); + } + + // Only the Stripe add-on contributes — get its expected items + const expectedItems = getExpectedNewProductItems(stripeAddOn); + expectSubscriptionItemsUpdate(result, expectedItems); + }); + + test("Excludes RevenueCat product even when only RC products are present", () => { + // If the only customer product is RC-managed (e.g. attaching a brand-new + // Stripe-managed product to a previously RC-only customer), no items + // should be produced for the RC product. + const rcMain = createProductWithAllPriceTypes({ + productId: "rc_only", + productName: "RC Only", + customerProductId: "cus_prod_rc_only", + }); + + const rcMainCustomerProduct = customerProducts.create({ + id: "cus_prod_rc_only", + productId: "rc_only", + product: rcMain.product, + customerPrices: createCustomerPricesForProduct({ + prices: rcMain.allPrices, + customerProductId: "cus_prod_rc_only", + }), + customerEntitlements: rcMain.allEntitlements, + options: rcMain.allOptions, + status: CusProductStatus.Active, + subscriptionIds: [], + processorType: ProcessorType.RevenueCat, + }); + + const ctx = contexts.create({ features: [] }); + const billingContext = contexts.createBilling({ + customerProducts: [rcMainCustomerProduct], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + finalCustomerProducts: [rcMainCustomerProduct], + }); + + // No items at all — the only customer product is RC-managed and gets filtered out. + expect(result).toHaveLength(0); + }); + }); }, ); diff --git a/server/tests/utils/fixtures/db/customerProducts.ts b/server/tests/utils/fixtures/db/customerProducts.ts index 397ed0e55..869fdc93c 100644 --- a/server/tests/utils/fixtures/db/customerProducts.ts +++ b/server/tests/utils/fixtures/db/customerProducts.ts @@ -7,6 +7,7 @@ import { type FullCustomerEntitlement, type FullCustomerPrice, type FullProduct, + type ProcessorType, } from "@autumn/shared"; import { products } from "./products"; @@ -26,6 +27,7 @@ const create = ({ status = CusProductStatus.Active, startsAt, endedAt, + processorType, }: { id?: string; productId?: string; @@ -39,6 +41,7 @@ const create = ({ status?: CusProductStatus; startsAt?: number; endedAt?: number | null; + processorType?: ProcessorType; }): FullCusProduct => ({ id, internal_product_id: `internal_${productId}`, @@ -59,6 +62,7 @@ const create = ({ collection_method: CollectionMethod.ChargeAutomatically, subscription_ids: subscriptionIds, scheduled_ids: [], + processor: processorType ? { type: processorType } : undefined, quantity: 1, api_semver: null, is_custom: false, diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts new file mode 100644 index 000000000..c412b02bd --- /dev/null +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts @@ -0,0 +1,27 @@ +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; +import { ProcessorType } from "@models/genModels/genEnums"; +import { cusProductToProcessorType } from "../convertCusProduct.js"; + +/** + * Filter customer products by processor type. + * + * `cusProductToProcessorType` treats an unset `processor` as Stripe (default), + * so passing `ProcessorType.Stripe` keeps both legacy unset rows and explicit + * Stripe-tagged rows. + * + * @param customerProducts - The customer products to filter + * @param processorType - The processor type to keep (e.g. `ProcessorType.Stripe`) + * @returns Customer products whose resolved processor type matches + */ +export const filterCustomerProductsByProcessorType = ({ + customerProducts, + processorType, +}: { + customerProducts: FullCusProduct[]; + processorType: ProcessorType; +}): FullCusProduct[] => { + return customerProducts.filter( + (customerProduct) => + cusProductToProcessorType(customerProduct) === processorType, + ); +}; diff --git a/shared/utils/cusProductUtils/index.ts b/shared/utils/cusProductUtils/index.ts index b18304d26..061f4db73 100644 --- a/shared/utils/cusProductUtils/index.ts +++ b/shared/utils/cusProductUtils/index.ts @@ -13,6 +13,7 @@ export * from "./featureOptionUtils/findFeatureOptions"; export * from "./featureOptionUtils/index"; export * from "./filterCusProductUtils"; export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js"; +export * from "./filterCustomerProducts/filterCustomerProductsByProcessorType.js"; export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js"; export * from "./findCustomerProduct/findActiveCustomerProduct.js"; export * from "./findCustomerProduct/findCustomerProduct.js"; From ff3825a432035550fb53d911a8775b3e1daa7852 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:40:34 +0100 Subject: [PATCH 6/9] =?UTF-8?q?test:=20=F0=9F=92=8D=20attach=20v2=20in=20r?= =?UTF-8?q?evcat=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../revenuecat-cross-processor-oneoff.test.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts b/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts index 7d4b1276d..41f5362a5 100644 --- a/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts +++ b/server/tests/integration/external-psps/revenuecat-cross-processor-oneoff.test.ts @@ -343,3 +343,164 @@ test.concurrent( ).rejects.toThrow(/Stripe|RevenueCat|external|managed/i); }, ); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: V2 attach — RC sub + Stripe one-off top-up via /v1/billing.attach +// +// Mirror of Test 2 but exercises the V2 attach pipeline (autumnV1.billing.attach +// → handleAttachV2 → handleAttachV2Errors → handleExternalPSPErrors v2). +// Confirms the V2 gate bypasses cross-processor for true one-offs. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("revenuecat cross-processor 4: rc sub + stripe one-off top-up via s.billing.attach (v2)")}`, + async () => { + const customerId = "rc-xproc-4"; + + const RC_PRO_MONTHLY_ID = "com.app.rc_xproc_4_pro_monthly"; + + const rcProMonthly = products.pro({ + id: "rc-xproc-4-pro-monthly", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + const webTopUp = products.oneOff({ + id: "rc-xproc-4-web-top-up", + items: [items.lifetimeMessages({ includedUsage: 100 })], + isAddOn: true, + }); + + await setupRevenueCatOrg(); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [rcProMonthly, webTopUp] }), + ], + actions: [], + }); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: rcProMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Step 1: RC initial purchase puts customer on the RC-managed subscription + const rcResult = await rcClient.initialPurchase({ + productId: RC_PRO_MONTHLY_ID, + appUserId: customerId, + originalTransactionId: "rc_xproc_4_tx_001", + }); + expectWebhookSuccess(rcResult); + + // Confirm pre-state: 1 product active (RC sub) + let customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(rcProMonthly.id); + + // PRIMARY ACTION: V2 attach the Stripe one-off top-up — must succeed because + // pricesOnlyOneOff(attachProduct.prices) === true bypasses the RC guard. + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: webTopUp.id, + }); + + customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(2); + const productIds = customer.products + .map((p: { id: string }) => p.id) + .sort(); + expect(productIds).toEqual([rcProMonthly.id, webTopUp.id].sort()); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: V2 attach negative guard — recurring add-on via /v1/billing.attach must +// STILL be blocked when customer has an RC-managed main product. +// +// This is the bug we just fixed: previously the V2 gate (`setupAttachTransitionContext` +// + handleExternalPSPErrors v2) bypassed the cross-processor check for ALL +// add-ons + one-offs because `currentCustomerProduct` resolved to undefined for +// non-main-recurring attaches. Now we scan all customer_products and only bypass +// for `pricesOnlyOneOff(attachProduct.prices)`. Recurring add-ons must throw. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("revenuecat cross-processor 5: rc sub + stripe recurring add-on via s.billing.attach must throw (v2)")}`, + async () => { + const customerId = "rc-xproc-5"; + + const RC_PRO_ID = "com.app.rc_xproc_5_pro"; + + const rcPro = products.pro({ + id: "rc-xproc-5-rc-pro", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + // Recurring add-on (NOT a one-off): cross-processor V2 attach must still throw. + const stripeRecurringAddOn = products.recurringAddOn({ + id: "rc-xproc-5-recurring-addon", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + + await setupRevenueCatOrg(); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [rcPro, stripeRecurringAddOn] }), + ], + actions: [], + }); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: rcPro.id, + revenuecat_product_ids: [RC_PRO_ID], + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + // Step 1: RC initial purchase puts customer on the RC-managed subscription + const rcResult = await rcClient.initialPurchase({ + productId: RC_PRO_ID, + appUserId: customerId, + originalTransactionId: "rc_xproc_5_tx_001", + }); + expectWebhookSuccess(rcResult); + + // PRIMARY ACTION: V2 attach a recurring add-on → must throw. + // Without the fix, this would silently succeed and create a brand-new Stripe + // subscription that bundles in the RC product's Stripe price (Runable bug). + await expect( + autumnV1.billing.attach({ + customer_id: customerId, + product_id: stripeRecurringAddOn.id, + }), + ).rejects.toThrow(/Stripe|RevenueCat|external|managed/i); + + // Sanity: the recurring add-on must NOT have been attached. + const customer = await autumnV1.customers.get(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(rcPro.id); + }, +); From eb2c063a6fbd8f4f4af5fb799bb89cb88ba3a5bb Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:56:44 +0100 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=F0=9F=90=9B=20clean=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ustomer-products-by-processor-type.spec.ts | 138 ++++++++++++++++++ ...ription-items-update-multi-product.spec.ts | 4 +- .../filterCustomerProductsByProcessorType.ts | 19 ++- 3 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts diff --git a/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts b/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts new file mode 100644 index 000000000..3932f7926 --- /dev/null +++ b/server/tests/unit/billing/filter-customer-products-by-processor-type.spec.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for filterCustomerProductsByProcessorType. + * + * Critical invariant: an unset `processor` field on a cus product MUST be + * treated as Stripe. Legacy Stripe-managed cus products do not tag the + * processor field; only RevenueCat-managed products explicitly set it. + */ + +import { describe, expect, test } from "bun:test"; +import { + filterCustomerProductsByProcessorType, + type FullCusProduct, + ProcessorType, +} from "@autumn/shared"; +import chalk from "chalk"; + +const baseCusProduct = (id: string): FullCusProduct => + ({ + id, + product: { name: id } as FullCusProduct["product"], + }) as FullCusProduct; + +describe( + chalk.yellowBright("filterCustomerProductsByProcessorType"), + () => { + test("includes cus product with unset processor when filtering for Stripe", () => { + const cp = baseCusProduct("legacy_stripe"); + // processor is undefined — legacy data shape + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("legacy_stripe"); + }); + + test("includes cus product with null processor when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("null_stripe"), + processor: null, + } as unknown as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + }); + + test("includes cus product with explicit Stripe processor when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("explicit_stripe"), + processor: { type: ProcessorType.Stripe }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(1); + }); + + test("excludes cus product with unset processor when filtering for RevenueCat", () => { + const cp = baseCusProduct("legacy_stripe_excluded"); + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(0); + }); + + test("includes cus product with explicit RevenueCat processor when filtering for RevenueCat", () => { + const cp = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(1); + }); + + test("excludes RevenueCat cus product when filtering for Stripe", () => { + const cp = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + const result = filterCustomerProductsByProcessorType({ + customerProducts: [cp], + processorType: ProcessorType.Stripe, + }); + expect(result).toHaveLength(0); + }); + + test("mixed list: filtering for Stripe keeps unset + explicit Stripe, drops RevenueCat", () => { + const legacyStripe = baseCusProduct("legacy_stripe"); + const explicitStripe = { + ...baseCusProduct("explicit_stripe"), + processor: { type: ProcessorType.Stripe }, + } as FullCusProduct; + const rc = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + + const result = filterCustomerProductsByProcessorType({ + customerProducts: [legacyStripe, explicitStripe, rc], + processorType: ProcessorType.Stripe, + }); + expect(result.map((cp) => cp.id).sort()).toEqual( + ["explicit_stripe", "legacy_stripe"].sort(), + ); + }); + + test("mixed list: filtering for RevenueCat keeps only explicit RC", () => { + const legacyStripe = baseCusProduct("legacy_stripe"); + const rc = { + ...baseCusProduct("rc"), + processor: { type: ProcessorType.RevenueCat }, + } as FullCusProduct; + + const result = filterCustomerProductsByProcessorType({ + customerProducts: [legacyStripe, rc], + processorType: ProcessorType.RevenueCat, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("rc"); + }); + + test("empty list returns empty list", () => { + expect( + filterCustomerProductsByProcessorType({ + customerProducts: [], + processorType: ProcessorType.Stripe, + }), + ).toEqual([]); + }); + }, +); diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts index 00aadb6a3..029e67120 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts @@ -1247,7 +1247,9 @@ describe( options: stripeAddOn.allOptions, status: CusProductStatus.Active, subscriptionIds: [], - // processor defaults to Stripe via cusProductToProcessorType + // IMPORTANT: processor is intentionally unset here — legacy Stripe-managed + // cus products have no `processor` field. The filter must treat unset as + // Stripe (so this product's items get included). RevenueCat is always tagged. }); const ctx = contexts.create({ features: [] }); diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts index c412b02bd..6cd0ba703 100644 --- a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByProcessorType.ts @@ -1,13 +1,14 @@ import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; import { ProcessorType } from "@models/genModels/genEnums"; -import { cusProductToProcessorType } from "../convertCusProduct.js"; /** * Filter customer products by processor type. * - * `cusProductToProcessorType` treats an unset `processor` as Stripe (default), - * so passing `ProcessorType.Stripe` keeps both legacy unset rows and explicit - * Stripe-tagged rows. + * IMPORTANT: a customer product with no `processor` set (or `processor.type` unset) + * is treated as Stripe. Historically, Stripe-managed cus products were created + * without explicitly tagging the processor field, so a null/undefined processor + * defaults to Stripe. RevenueCat-managed products always have + * `processor.type === ProcessorType.RevenueCat` explicitly set. * * @param customerProducts - The customer products to filter * @param processorType - The processor type to keep (e.g. `ProcessorType.Stripe`) @@ -20,8 +21,10 @@ export const filterCustomerProductsByProcessorType = ({ customerProducts: FullCusProduct[]; processorType: ProcessorType; }): FullCusProduct[] => { - return customerProducts.filter( - (customerProduct) => - cusProductToProcessorType(customerProduct) === processorType, - ); + return customerProducts.filter((customerProduct) => { + // Default unset processor to Stripe — RevenueCat is always explicitly tagged. + const cusProductProcessorType = + customerProduct.processor?.type ?? ProcessorType.Stripe; + return cusProductProcessorType === processorType; + }); }; From 8528f9dcd73a4bbf6a8ab4c8922f3070e3d88872 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:18:28 +0100 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=F0=9F=90=9B=20address=20pr=20commen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/errors/handleExternalPSPErrors.ts | 43 +++++++--- .../handle-external-psp-errors.spec.ts | 84 ++++++++++++++++++- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts b/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts index 4d819b4d1..eeb37b3a5 100644 --- a/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts +++ b/server/src/internal/billing/v2/common/errors/handleExternalPSPErrors.ts @@ -1,4 +1,5 @@ import { + cusProductToPrices, cusProductToProcessorType, type FullCusProduct, type FullProduct, @@ -13,15 +14,19 @@ import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; * * For `update`: validates the specific cusProduct being modified. * - * For `attach`: scans the customer's existing products. Throws if any are - * managed by a non-Stripe processor — UNLESS the product being attached is a - * true one-off (no recurring prices). True one-off attaches are safe across - * processors because they create a parallel cus_product without replacing - * the customer's existing subscription, and they never spin up a new Stripe - * subscription that could conflict with an RC-managed plan. + * For `attach`: scans the customer's existing products. Throws if any + * RECURRING product is managed by a non-Stripe processor — UNLESS the product + * being attached is itself a true one-off (no recurring prices). One-off + * cross-processor purchases (in either direction) are safe: they create a + * parallel cus_product, never spin up or reuse a recurring Stripe subscription, + * and so cannot conflict with the existing external subscription. * - * Recurring add-ons are NOT exempt — they create a Stripe subscription that - * would coexist with the RC-managed main product, leading to incorrect billing. + * Concretely: + * - external recurring + attaching anything → throw (would create / mutate + * a Stripe sub that coexists or collides with the external sub). + * - external recurring + attaching one-off → bypass (parallel one-off only). + * - external one-off only + attaching anything → bypass (no external sub + * exists to conflict with). */ export const handleExternalPSPErrors = ({ customerProduct, @@ -60,13 +65,25 @@ export const handleExternalPSPErrors = ({ return; } - const externalCusProduct = customerProducts.find( - (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, - ); + // Only block on EXTERNAL RECURRING products. External one-off-only products + // (e.g. a previously-purchased RC one-off pack) don't have a recurring + // subscription and so can't conflict with the new Stripe attach. + const conflictingExternalCusProduct = customerProducts.find((cp) => { + const isExternal = + cusProductToProcessorType(cp) !== ProcessorType.Stripe; + if (!isExternal) return false; - if (externalCusProduct) { + // Skip external products that are pure one-offs — they have no + // recurring sub to conflict with. Prices live on customer_prices + // (FullCusProduct.product is the bare Product without prices). + const cpPrices = cusProductToPrices({ cusProduct: cp }); + const cpIsOneOffOnly = pricesOnlyOneOff(cpPrices); + return !cpIsOneOffOnly; + }); + + if (conflictingExternalCusProduct) { throw new RecaseError({ - message: `Cannot attach because the customer's current product '${externalCusProduct.product.name}' is managed by RevenueCat.`, + message: `Cannot attach because the customer's current product '${conflictingExternalCusProduct.product.name}' is managed by RevenueCat.`, }); } }; diff --git a/server/tests/unit/billing/handle-external-psp-errors.spec.ts b/server/tests/unit/billing/handle-external-psp-errors.spec.ts index b14ebc34f..8b5822dda 100644 --- a/server/tests/unit/billing/handle-external-psp-errors.spec.ts +++ b/server/tests/unit/billing/handle-external-psp-errors.spec.ts @@ -82,13 +82,34 @@ const buildMixedIntervalProduct = (id: string): FullProduct => ], }); -const buildRcCusProduct = (id = "cus_prod_rc"): FullCusProduct => - customerProducts.create({ +const buildRcCusProduct = (id = "cus_prod_rc"): FullCusProduct => { + const product = buildRecurringProduct("rc_main", false); + return customerProducts.create({ id, productId: "rc_main", + product, + customerPrices: product.prices.map((price) => + priceFixtures.createCustomer({ price, customerProductId: id }), + ), processorType: ProcessorType.RevenueCat, subscriptionIds: [], }); +}; + +/** RC-managed cus product whose underlying product has only one-off prices. */ +const buildRcOneOffCusProduct = (id = "cus_prod_rc_oneoff"): FullCusProduct => { + const product = buildOneOffProduct("rc_oneoff", true); + return customerProducts.create({ + id, + productId: "rc_oneoff", + product, + customerPrices: product.prices.map((price) => + priceFixtures.createCustomer({ price, customerProductId: id }), + ), + processorType: ProcessorType.RevenueCat, + subscriptionIds: [], + }); +}; const buildStripeCusProduct = (id = "cus_prod_stripe"): FullCusProduct => customerProducts.create({ @@ -259,5 +280,64 @@ describe( "managed by RevenueCat", ); }); + + // ─── External one-off-only products are NOT a conflict ────────────────── + test("BYPASS: customer has only an RC ONE-OFF product, attaching a Stripe recurring", () => { + // RC one-off (e.g. an in-app topup) doesn't have a recurring sub — + // nothing to conflict with the new Stripe attach. + const rcOneOff = buildRcOneOffCusProduct(); + const recurringMain = buildRecurringProduct("pro_25_monthly", false); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [rcOneOff], + attachProduct: recurringMain, + action: "attach", + }), + ).not.toThrow(); + }); + + test("BYPASS: customer has only an RC ONE-OFF product, attaching a Stripe recurring add-on", () => { + const rcOneOff = buildRcOneOffCusProduct(); + const recurringAddOn = buildRecurringProduct("recurring_addon", true); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [rcOneOff], + attachProduct: recurringAddOn, + action: "attach", + }), + ).not.toThrow(); + }); + + test("THROWS: customer has BOTH RC recurring and RC one-off, attaching a Stripe recurring", () => { + // The one-off is benign but the recurring product still conflicts. + const rcRecurring = buildRcCusProduct("cus_prod_rc_recurring"); + const rcOneOff = buildRcOneOffCusProduct("cus_prod_rc_oneoff"); + const recurringMain = buildRecurringProduct("pro_50_monthly", false); + + expectThrows( + () => + handleExternalPSPErrors({ + customerProducts: [rcOneOff, rcRecurring], + attachProduct: recurringMain, + action: "attach", + }), + "managed by RevenueCat", + ); + }); + + test("BYPASS: customer has only an RC ONE-OFF, no attachProduct provided", () => { + // Defensive: even without attachProduct, an RC one-off shouldn't + // trigger the guard since there's no recurring conflict. + const rcOneOff = buildRcOneOffCusProduct(); + + expect(() => + handleExternalPSPErrors({ + customerProducts: [rcOneOff], + action: "attach", + }), + ).not.toThrow(); + }); }, ); From e452d6ab123a8c55648e54531998de68d34e4efd Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Wed, 29 Apr 2026 14:57:51 +0100 Subject: [PATCH 9/9] Fix balance edit failing for entity-scoped entitlements when no entity selected Derive entity_id from the selected customer entitlement instead of relying solely on the top-level entity dropdown context, which is null when unset. Consolidate the entity lookup into BalanceEditSheet and pass it down as a prop to keep the logic DRY. Made-with: Cursor --- .../components/sheets/BalanceEditSheet.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx index ec765d824..fa1ab51af 100644 --- a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx @@ -78,6 +78,17 @@ export function BalanceEditSheet() { cp.price.entitlement_id === selectedCusEnt.entitlement.id, ); + const derivedEntity = customer?.entities?.find((e: Entity) => { + if (selectedCusEnt.internal_entity_id) + return e.internal_id === selectedCusEnt.internal_entity_id; + return ( + e.internal_id === cusProduct?.internal_entity_id || + e.id === cusProduct?.entity_id + ); + }); + const effectiveEntityId = + entityId ?? derivedEntity?.id ?? derivedEntity?.internal_id ?? null; + return (
) : ( { - if (selectedCusEnt.internal_entity_id) { - return e.internal_id === selectedCusEnt.internal_entity_id; - } - return ( - e.internal_id === cusProduct?.internal_entity_id || - e.id === cusProduct?.entity_id - ); - }); - return (
{selectedCusEnt.external_id && (