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] =?UTF-8?q?test:=20=F0=9F=92=8D=20attach=20v2=20in=20revca?= =?UTF-8?q?t=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); + }, +);