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 type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
|
||||||
import { CusService } from "@/internal/customers/CusService";
|
import { CusService } from "@/internal/customers/CusService";
|
||||||
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
|
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||||
|
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
|
||||||
import { ProductService } from "@/internal/products/ProductService";
|
import { ProductService } from "@/internal/products/ProductService";
|
||||||
import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer";
|
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 (
|
if (
|
||||||
|
!incomingIsOneOff &&
|
||||||
customer.customer_products.some(
|
customer.customer_products.some(
|
||||||
(cp) =>
|
(cp) =>
|
||||||
cp.processor?.type !== ProcessorType.RevenueCat &&
|
cp.processor?.type !== ProcessorType.RevenueCat &&
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export const handleAttachV2Errors = async ({
|
|||||||
|
|
||||||
// 1. External PSP errors (RevenueCat)
|
// 1. External PSP errors (RevenueCat)
|
||||||
handleExternalPSPErrors({
|
handleExternalPSPErrors({
|
||||||
customerProduct: billingContext.currentCustomerProduct,
|
customerProducts: billingContext.fullCustomer.customer_products,
|
||||||
|
attachProduct: billingContext.attachProduct,
|
||||||
action: "attach",
|
action: "attach",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,89 @@
|
|||||||
import {
|
import {
|
||||||
|
cusProductToPrices,
|
||||||
cusProductToProcessorType,
|
cusProductToProcessorType,
|
||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
|
type FullProduct,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
RecaseError,
|
RecaseError,
|
||||||
} from "@autumn/shared";
|
} 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 = ({
|
export const handleExternalPSPErrors = ({
|
||||||
customerProduct,
|
customerProduct,
|
||||||
|
customerProducts,
|
||||||
|
attachProduct,
|
||||||
action,
|
action,
|
||||||
}: {
|
}: {
|
||||||
|
/** For `update`: the specific customer product being modified. */
|
||||||
customerProduct?: FullCusProduct;
|
customerProduct?: FullCusProduct;
|
||||||
|
/** For `attach`: all of the customer's current products. */
|
||||||
|
customerProducts?: FullCusProduct[];
|
||||||
|
/** For `attach`: the product being attached. */
|
||||||
|
attachProduct?: FullProduct;
|
||||||
action: "attach" | "update";
|
action: "attach" | "update";
|
||||||
}) => {
|
}) => {
|
||||||
|
if (action === "update") {
|
||||||
if (!customerProduct) return;
|
if (!customerProduct) return;
|
||||||
|
|
||||||
const processorType = cusProductToProcessorType(customerProduct);
|
const processorType = cusProductToProcessorType(customerProduct);
|
||||||
if (processorType === ProcessorType.RevenueCat) {
|
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.`;
|
|
||||||
|
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message,
|
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: `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 type { BillingContext, StripeItemSpec } from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
filterCustomerProductsByActiveStatuses,
|
filterCustomerProductsByActiveStatuses,
|
||||||
|
filterCustomerProductsByProcessorType,
|
||||||
filterCustomerProductsByStripeSubscriptionId,
|
filterCustomerProductsByStripeSubscriptionId,
|
||||||
|
ProcessorType,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
@@ -94,19 +96,30 @@ export const buildStripeSubscriptionItemsUpdate = ({
|
|||||||
stripeSubscriptionId: billingContext.stripeSubscription?.id,
|
stripeSubscriptionId: billingContext.stripeSubscription?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Filter customer products by active statuses
|
// 2. Drop customer products managed by a non-Stripe processor (e.g. RevenueCat).
|
||||||
const activeCustomerProducts = filterCustomerProductsByActiveStatuses({
|
//
|
||||||
|
// `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,
|
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({
|
const recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
customerProducts: activeCustomerProducts,
|
customerProducts: activeCustomerProducts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Diff against current subscription items
|
// 5. Diff against current subscription items
|
||||||
return stripeItemSpecsToSubItemsUpdate({
|
return stripeItemSpecsToSubItemsUpdate({
|
||||||
billingContext,
|
billingContext,
|
||||||
stripeItemSpecs: recurringStripeItemSpecs,
|
stripeItemSpecs: recurringStripeItemSpecs,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const lazyResetSubjectEntitlements = async ({
|
|||||||
|
|
||||||
const allCustomerEntitlements = fullSubjectToCustomerEntitlements({
|
const allCustomerEntitlements = fullSubjectToCustomerEntitlements({
|
||||||
fullSubject,
|
fullSubject,
|
||||||
inStatuses: [CusProductStatus.Active],
|
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||||
});
|
});
|
||||||
|
|
||||||
const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({
|
const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {
|
|||||||
AttachBranch,
|
AttachBranch,
|
||||||
type AttachConfig,
|
type AttachConfig,
|
||||||
BillingType,
|
BillingType,
|
||||||
cusProductToProcessorType,
|
|
||||||
ErrCode,
|
ErrCode,
|
||||||
ProcessorType,
|
|
||||||
RecaseError,
|
RecaseError,
|
||||||
TierBehavior,
|
TierBehavior,
|
||||||
type UsagePriceConfig,
|
type UsagePriceConfig,
|
||||||
@@ -20,6 +18,7 @@ import {
|
|||||||
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
|
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
|
||||||
import type { AttachParams } from "../../cusProducts/AttachParams.js";
|
import type { AttachParams } from "../../cusProducts/AttachParams.js";
|
||||||
import type { AttachFlags } from "../models/AttachFlags.js";
|
import type { AttachFlags } from "../models/AttachFlags.js";
|
||||||
|
import { handleExternalPSPErrors } from "./handleAttachErrors/handleExternalPSPErrors.js";
|
||||||
import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js";
|
import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js";
|
||||||
|
|
||||||
const handleNonCheckoutErrors = ({
|
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 = ({
|
export const handlePrepaidVolumeErrors = ({
|
||||||
attachParams,
|
attachParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { AttachBranch } from "@autumn/shared";
|
import type { AttachBranch } from "@autumn/shared";
|
||||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||||
import {
|
import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js";
|
||||||
handleCustomPaymentMethodErrors,
|
import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js";
|
||||||
handleExternalPSPErrors,
|
|
||||||
} from "../handleAttachErrors.js";
|
|
||||||
|
|
||||||
export const handleCheckoutErrors = ({
|
export const handleCheckoutErrors = ({
|
||||||
attachParams,
|
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";
|
} from "@autumn/shared";
|
||||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||||
import RecaseError from "@/utils/errorUtils.js";
|
import RecaseError from "@/utils/errorUtils.js";
|
||||||
|
import { handleExternalPSPErrors } from "./handleExternalPSPErrors.js";
|
||||||
|
|
||||||
export const handleMultiAttachErrors = async ({
|
export const handleMultiAttachErrors = async ({
|
||||||
attachParams,
|
attachParams,
|
||||||
@@ -20,6 +21,10 @@ export const handleMultiAttachErrors = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { products, prices, productsList } = attachParams;
|
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 }));
|
const usagePrice = prices.find((p: Price) => isUsagePrice({ price: p }));
|
||||||
|
|
||||||
// 1. Don't support usage prices just yet...
|
// 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,
|
AppEnv,
|
||||||
CusProductStatus,
|
CusProductStatus,
|
||||||
customers,
|
customers,
|
||||||
|
revenuecatMappings,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, arrayOverlaps, eq } from "drizzle-orm";
|
||||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
||||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||||
@@ -176,13 +177,50 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
|||||||
webhookSecret: RC_WEBHOOK_SECRET,
|
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([
|
await Promise.all([
|
||||||
initProductsV0({
|
initProductsV0({
|
||||||
ctx,
|
ctx,
|
||||||
products: [proMonthly, proYearly, addOnPack],
|
products: [proMonthly, proYearly, addOnPack],
|
||||||
prefix: testCase,
|
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({
|
RCMappingService.upsert({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
data: {
|
data: {
|
||||||
@@ -210,11 +248,6 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
|||||||
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
|
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
initCustomerV3({
|
|
||||||
ctx,
|
|
||||||
customerId,
|
|
||||||
withTestClock: false,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
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 { 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 { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||||
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||||
import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions";
|
import { stripeSubscriptions } from "@tests/utils/fixtures/stripe/subscriptions";
|
||||||
@@ -1200,5 +1200,128 @@ describe(
|
|||||||
expect(result[0].price).toBe("stripe_pro_consumable");
|
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 FullCustomerEntitlement,
|
||||||
type FullCustomerPrice,
|
type FullCustomerPrice,
|
||||||
type FullProduct,
|
type FullProduct,
|
||||||
|
type ProcessorType,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { products } from "./products";
|
import { products } from "./products";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const create = ({
|
|||||||
status = CusProductStatus.Active,
|
status = CusProductStatus.Active,
|
||||||
startsAt,
|
startsAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
|
processorType,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
@@ -39,6 +41,7 @@ const create = ({
|
|||||||
status?: CusProductStatus;
|
status?: CusProductStatus;
|
||||||
startsAt?: number;
|
startsAt?: number;
|
||||||
endedAt?: number | null;
|
endedAt?: number | null;
|
||||||
|
processorType?: ProcessorType;
|
||||||
}): FullCusProduct => ({
|
}): FullCusProduct => ({
|
||||||
id,
|
id,
|
||||||
internal_product_id: `internal_${productId}`,
|
internal_product_id: `internal_${productId}`,
|
||||||
@@ -59,6 +62,7 @@ const create = ({
|
|||||||
collection_method: CollectionMethod.ChargeAutomatically,
|
collection_method: CollectionMethod.ChargeAutomatically,
|
||||||
subscription_ids: subscriptionIds,
|
subscription_ids: subscriptionIds,
|
||||||
scheduled_ids: [],
|
scheduled_ids: [],
|
||||||
|
processor: processorType ? { type: processorType } : undefined,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
api_semver: null,
|
api_semver: null,
|
||||||
is_custom: false,
|
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 "./filterCusProductUtils";
|
||||||
export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
|
export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
|
||||||
export * from "./filterCustomerProducts/filterCustomerProductsByFeatureId.js";
|
export * from "./filterCustomerProducts/filterCustomerProductsByFeatureId.js";
|
||||||
|
export * from "./filterCustomerProducts/filterCustomerProductsByProcessorType.js";
|
||||||
export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
|
export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
|
||||||
export * from "./findCustomerProduct/findActiveCustomerProduct.js";
|
export * from "./findCustomerProduct/findActiveCustomerProduct.js";
|
||||||
export * from "./findCustomerProduct/findCustomerProduct.js";
|
export * from "./findCustomerProduct/findCustomerProduct.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user