chore: merge with main

This commit is contained in:
Charlie Lamb
2026-04-29 16:19:40 +01:00
17 changed files with 1332 additions and 47 deletions

View File

@@ -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 &&

View File

@@ -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",
});

View File

@@ -1,31 +1,89 @@
import {
cusProductToPrices,
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
* 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.
*
* 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,
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;
}
// 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;
// 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,
message: `Cannot attach because the customer's current product '${conflictingExternalCusProduct.product.name}' is managed by RevenueCat.`,
});
}
};

View File

@@ -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,

View File

@@ -64,7 +64,7 @@ export const lazyResetSubjectEntitlements = async ({
const allCustomerEntitlements = fullSubjectToCustomerEntitlements({
fullSubject,
inStatuses: [CusProductStatus.Active],
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({

View File

@@ -3,9 +3,7 @@ import {
AttachBranch,
type AttachConfig,
BillingType,
cusProductToProcessorType,
ErrCode,
ProcessorType,
RecaseError,
TierBehavior,
type UsagePriceConfig,
@@ -20,6 +18,7 @@ import {
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 = ({
@@ -157,23 +156,6 @@ export const handleCustomPaymentMethodErrors = ({
}
};
export const handleExternalPSPErrors = ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
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,
}: {

View File

@@ -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,

View File

@@ -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.",
});
}
};

View File

@@ -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 "./handleExternalPSPErrors.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...

View File

@@ -0,0 +1,506 @@
/**
* 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);
},
);
// ═══════════════════════════════════════════════════════════════════════════════
// 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);
},
);

View File

@@ -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({

View File

@@ -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([]);
});
},
);

View File

@@ -0,0 +1,343 @@
/**
* 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 => {
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({
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",
);
});
// ─── 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();
});
},
);

View File

@@ -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,128 @@ 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: [],
// 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: [] });
// 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);
});
});
},
);

View File

@@ -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,

View File

@@ -0,0 +1,30 @@
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
import { ProcessorType } from "@models/genModels/genEnums";
/**
* Filter customer products by processor type.
*
* 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`)
* @returns Customer products whose resolved processor type matches
*/
export const filterCustomerProductsByProcessorType = ({
customerProducts,
processorType,
}: {
customerProducts: FullCusProduct[];
processorType: ProcessorType;
}): FullCusProduct[] => {
return customerProducts.filter((customerProduct) => {
// Default unset processor to Stripe — RevenueCat is always explicitly tagged.
const cusProductProcessorType =
customerProduct.processor?.type ?? ProcessorType.Stripe;
return cusProductProcessorType === processorType;
});
};

View File

@@ -14,6 +14,7 @@ export * from "./featureOptionUtils/index";
export * from "./filterCusProductUtils";
export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
export * from "./filterCustomerProducts/filterCustomerProductsByFeatureId.js";
export * from "./filterCustomerProducts/filterCustomerProductsByProcessorType.js";
export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
export * from "./findCustomerProduct/findActiveCustomerProduct.js";
export * from "./findCustomerProduct/findCustomerProduct.js";