chore: merge with main
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const lazyResetSubjectEntitlements = async ({
|
||||
|
||||
const allCustomerEntitlements = fullSubjectToCustomerEntitlements({
|
||||
fullSubject,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
});
|
||||
|
||||
const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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...
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
343
server/tests/unit/billing/handle-external-psp-errors.spec.ts
Normal file
343
server/tests/unit/billing/handle-external-psp-errors.spec.ts
Normal 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();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user