added integration tests
This commit is contained in:
@@ -105,10 +105,12 @@ export const createProduct = async ({
|
||||
free_trial: newFreeTrial,
|
||||
};
|
||||
|
||||
await initProductInStripe({
|
||||
ctx,
|
||||
product: newFullProduct,
|
||||
});
|
||||
if (data.create_in_stripe !== false) {
|
||||
await initProductInStripe({
|
||||
ctx,
|
||||
product: newFullProduct,
|
||||
});
|
||||
}
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.DetectBaseVariant,
|
||||
|
||||
@@ -131,10 +131,12 @@ export const handleCreatePlan = createRoute({
|
||||
free_trial: newFreeTrial,
|
||||
};
|
||||
|
||||
await initProductInStripe({
|
||||
ctx,
|
||||
product: newFullProduct,
|
||||
});
|
||||
if (v1_2Body.create_in_stripe !== false) {
|
||||
await initProductInStripe({
|
||||
ctx,
|
||||
product: newFullProduct,
|
||||
});
|
||||
}
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.DetectBaseVariant,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const initProductsV0 = async ({
|
||||
skipPrefixIds = [],
|
||||
customerId,
|
||||
customerIds,
|
||||
createInStripe,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
products: ProductV2[];
|
||||
@@ -18,6 +19,7 @@ export const initProductsV0 = async ({
|
||||
skipPrefixIds?: string[];
|
||||
customerId?: string;
|
||||
customerIds?: string[];
|
||||
createInStripe?: boolean;
|
||||
}) => {
|
||||
// 1. Add prefix to products (except those in skipPrefixIds)
|
||||
if (prefix) {
|
||||
@@ -55,5 +57,6 @@ export const initProductsV0 = async ({
|
||||
env: ctx.env,
|
||||
autumn: autumn,
|
||||
products,
|
||||
createInStripe,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Preview billing actions must not create Stripe resources.
|
||||
*
|
||||
* Contract under test:
|
||||
* - A plan created with create_in_stripe=false stays Stripe-less after
|
||||
* these preview endpoints run:
|
||||
* * POST /billing.preview_attach
|
||||
* * POST /billing.preview_create_schedule (multi-phase)
|
||||
* * POST /billing.preview_update (with customize)
|
||||
* - No is_custom prices with Stripe IDs are persisted by customize
|
||||
* previews.
|
||||
* - Item coverage: monthlyMessages (metered), prepaidUsers,
|
||||
* consumableWords, allocatedWorkflows.
|
||||
*
|
||||
* Implementation surface:
|
||||
* server/src/internal/billing/v2/providers/stripe/utils/common/
|
||||
* initStripeResourcesForProducts.ts — early returns via
|
||||
* applyPreviewStripeResourcesToBillingPlan when dryRunStripe=true.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type {
|
||||
AttachPreviewResponse,
|
||||
CreateScheduleParamsV0Input,
|
||||
UpdateSubscriptionV1ParamsInput,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
expectNoCustomStripePrices,
|
||||
expectNoStripeResources,
|
||||
} from "@tests/integration/billing/misc/utils/expectNoStripeResources";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
const buildItems = () => [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.prepaidUsers({ billingUnits: 1 }),
|
||||
items.consumableWords({ includedUsage: 0 }),
|
||||
items.allocatedWorkflows({ includedUsage: 0 }),
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: previewAttach against a plan created with create_in_stripe=false
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright(
|
||||
"previewAttach on no-stripe plan: preview totals correct, no Stripe IDs created",
|
||||
)}`, async () => {
|
||||
const customerId = "preview-no-stripe-attach";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-no-stripe-attach",
|
||||
items: buildItems(),
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan], createInStripe: false }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// s.products mutates proPlan.id to include the `_${customerId}` prefix.
|
||||
const preview = (await autumnV2_2.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
})) as AttachPreviewResponse;
|
||||
|
||||
expect(preview.subtotal).toBe(20);
|
||||
expect(preview.total).toBe(20);
|
||||
expect(preview.currency.toLowerCase()).toBe("usd");
|
||||
|
||||
await expectNoStripeResources({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
productId: proPlan.id,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: preview_create_schedule with multiple phases on no-stripe plans
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright(
|
||||
"preview_create_schedule multi-phase on no-stripe plans: preview works, no Stripe IDs created",
|
||||
)}`, async () => {
|
||||
const customerId = "preview-no-stripe-schedule";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-no-stripe-schedule",
|
||||
items: buildItems(),
|
||||
});
|
||||
const premiumPlan = products.premium({
|
||||
id: "premium-no-stripe-schedule",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1, advancedTo, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan, premiumPlan], createInStripe: false }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: CreateScheduleParamsV0Input = {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 35 }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
starts_at: advancedTo + 30 * 24 * 60 * 60 * 1000,
|
||||
plans: [{ plan_id: premiumPlan.id }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const preview = (await autumnV1.post(
|
||||
"/billing.preview_create_schedule",
|
||||
params,
|
||||
)) as AttachPreviewResponse;
|
||||
|
||||
expect(preview.total).toBe(35);
|
||||
expect(preview.subtotal).toBe(35);
|
||||
|
||||
await expectNoStripeResources({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
productId: proPlan.id,
|
||||
});
|
||||
await expectNoStripeResources({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
productId: premiumPlan.id,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: previewUpdate with customize on an active no-stripe sub. The customer
|
||||
// is already on a no-stripe plan attached via /billing.preview_attach +
|
||||
// /billing.preview_update is impossible — instead we attach the no-stripe plan
|
||||
// via attach which would normally create Stripe resources. To avoid that, we
|
||||
// rely on the contract that any sub created via real attach on a no-stripe
|
||||
// plan still preview-cleanly. We attach a stripe-backed plan A so the customer
|
||||
// has an active sub, then previewUpdate with customize.items shapes drawn from
|
||||
// every payable category; the assertion is that no is_custom prices got
|
||||
// persisted with Stripe IDs by the preview.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright(
|
||||
"previewUpdate customize on active sub: no is_custom prices with Stripe IDs created",
|
||||
)}`, async () => {
|
||||
const customerId = "preview-no-stripe-update";
|
||||
|
||||
const stripeBackedPlan = products.pro({
|
||||
id: "pro-with-stripe-update",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [stripeBackedPlan] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: stripeBackedPlan.id })],
|
||||
});
|
||||
|
||||
const fullBefore = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: stripeBackedPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const params: UpdateSubscriptionV1ParamsInput = {
|
||||
customer_id: customerId,
|
||||
plan_id: stripeBackedPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 30 }),
|
||||
},
|
||||
};
|
||||
|
||||
const preview = await autumnV2_2.subscriptions.previewUpdate(params);
|
||||
expect(typeof preview.total).toBe("number");
|
||||
|
||||
await expectNoCustomStripePrices({
|
||||
db: ctx.db,
|
||||
internalProductId: fullBefore.internal_id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Custom plans reuse the base plan's Stripe resources where possible.
|
||||
*
|
||||
* When a customer attaches a plan with `customize.items` (or `customize.price`),
|
||||
* a new `is_custom: true` price row is written for each affected price. The
|
||||
* carry-forward path in handleNewProductItems.carryForwardStripeResources
|
||||
* (which calls copyStripeResourcesToMatchingPrice) MUST populate the new row's
|
||||
* `stripe_*_id` fields from the matching catalog price so we don't mint
|
||||
* duplicate Stripe Price objects.
|
||||
*
|
||||
* Contract under test:
|
||||
* - Adding an unrelated boolean entitlement (dashboard) keeps every existing
|
||||
* price's Stripe IDs intact.
|
||||
* - Same for the paid feature shapes prepaid / consumable / allocated.
|
||||
* - Negative: swapping prepaid → consumable on the same feature does NOT
|
||||
* reuse stripe_price_id (different price.config.type / billing_method).
|
||||
* - Negative: changing the price amount on a prepaid item does NOT reuse
|
||||
* stripe_price_id (config differs → pricesAreSame=false → reuse level
|
||||
* drops below "full").
|
||||
* - Negative: changing tier amounts on a tiered prepaid item does NOT reuse
|
||||
* stripe_price_id.
|
||||
*
|
||||
* Implementation surface:
|
||||
* server/src/internal/products/product-items/productItemUtils/
|
||||
* handleNewProductItems.ts — calls carryForwardStripeResources before
|
||||
* persisting new prices.
|
||||
* shared/utils/productUtils/priceUtils/match/
|
||||
* copyStripeResourcesToMatchingPrice.ts + getPriceStripeReuseLevel.ts —
|
||||
* the actual matching + copy logic.
|
||||
* shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts —
|
||||
* boolean predicate used by the test helpers.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import {
|
||||
type AttachParamsV1Input,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
RolloverExpiryDurationType,
|
||||
TierBehavior,
|
||||
TierInfinite,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
expectAllStripeIdsReused,
|
||||
expectStripePriceIdNotReused,
|
||||
loadCustomerAndCatalogPrices,
|
||||
} from "@tests/integration/billing/misc/utils/findCatalogAndCustomPrices";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: custom plan adds a boolean entitlement, base price reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: add boolean entitlement → base price Stripe IDs reused")}`, async () => {
|
||||
const customerId = "reuse-custom-boolean";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-boolean",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
itemsV2.monthlyMessages({ included: 100 }),
|
||||
itemsV2.dashboard(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectAllStripeIdsReused({ pairs });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: custom plan keeps prepaid/consumable/allocated items → all reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: paid feature shapes unchanged → all Stripe IDs reused")}`, async () => {
|
||||
const customerId = "reuse-custom-paid";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-paid",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.prepaidUsers({ billingUnits: 1 }),
|
||||
items.consumableWords({ includedUsage: 0 }),
|
||||
items.allocatedWorkflows({ includedUsage: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
itemsV2.monthlyMessages({ included: 100 }),
|
||||
itemsV2.prepaidUsers({ amount: 10, billingUnits: 1 }),
|
||||
itemsV2.consumableWords({ amount: 0.05 }),
|
||||
itemsV2.allocatedWorkflows({ amount: 10 }),
|
||||
itemsV2.dashboard(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectAllStripeIdsReused({ pairs });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3 (negative): swap prepaid → consumable on same feature → no reuse
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: prepaid → consumable on same feature → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-prepaid-to-consumable";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-prepaid-to-consumable",
|
||||
items: [items.prepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [itemsV2.consumableMessages({ amount: 0.5 })],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs, catalogPrices, customerPrices } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({
|
||||
pairs,
|
||||
featureId: TestFeature.Messages,
|
||||
catalogPrices,
|
||||
customerPrices,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4 (negative): change prepaid price amount → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: prepaid amount change → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-prepaid-amount";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-prepaid-amount",
|
||||
items: [items.prepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [itemsV2.prepaidMessages({ amount: 25, billingUnits: 100 })],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5 (negative): change tier amounts on tiered prepaid → not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: tier amount change → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-tier";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-tier",
|
||||
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
itemsV2.tieredPrepaidMessages({
|
||||
tiers: [
|
||||
{ to: 600, amount: 20 },
|
||||
{ to: TierInfinite, amount: 10 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 6 (negative): graduated → volume tier_behavior → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: graduated → volume tier_behavior → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-tier-behavior";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-tier-behavior",
|
||||
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
included: 0,
|
||||
price: {
|
||||
tiers: [
|
||||
{ to: 500, amount: 10 },
|
||||
{ to: TierInfinite, amount: 5 },
|
||||
],
|
||||
tier_behavior: TierBehavior.VolumeBased,
|
||||
interval: BillingInterval.Month,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
billing_units: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 7 (negative): add flat_amount to a tier → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: add flat_amount to tier → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-flat-amount";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-flat-amount",
|
||||
items: [items.volumePrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
included: 0,
|
||||
price: {
|
||||
tiers: [
|
||||
{ to: 500, amount: 10, flat_amount: 100 },
|
||||
{ to: TierInfinite, amount: 5 },
|
||||
],
|
||||
tier_behavior: TierBehavior.VolumeBased,
|
||||
interval: BillingInterval.Month,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
billing_units: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 8 (negative): change proration_config on allocated → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: change proration_config on allocated → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-proration";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-proration",
|
||||
items: [items.allocatedWorkflows({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
{
|
||||
feature_id: TestFeature.Workflows,
|
||||
included: 0,
|
||||
price: {
|
||||
amount: 10,
|
||||
interval: BillingInterval.Month,
|
||||
billing_method: BillingMethod.UsageBased,
|
||||
billing_units: 1,
|
||||
},
|
||||
proration: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.Prorate,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Workflows });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 9 (negative): change billing_units → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: change prepaid billing_units → stripe_price_id NOT reused")}`, async () => {
|
||||
const customerId = "reuse-custom-billing-units";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-billing-units",
|
||||
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 50 })],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 10 (positive): change rollover config on ent (base price unaffected)
|
||||
// Rollover lives on the entitlement. monthlyMessagesWithRollover has no price,
|
||||
// so the only paid line on this plan is the $20 base — which has no paired ent
|
||||
// and thus is unaffected by ent rollover diffs. Asserts the base price still
|
||||
// reuses all Stripe IDs across the customize.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: change rollover config → base price Stripe IDs still reused")}`, async () => {
|
||||
const customerId = "reuse-custom-rollover";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-rollover",
|
||||
items: [
|
||||
items.monthlyMessagesWithRollover({
|
||||
includedUsage: 200,
|
||||
rolloverConfig: {
|
||||
max: 100,
|
||||
length: 0,
|
||||
duration: RolloverExpiryDurationType.Forever,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
included: 200,
|
||||
rollover: {
|
||||
max: 500,
|
||||
expiry_duration_type: RolloverExpiryDurationType.Forever,
|
||||
expiry_duration_length: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectAllStripeIdsReused({ pairs });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 11 (positive): prepaid + consumable pair on same feature → both reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("custom plan: prepaid + consumable pair on same feature → both Stripe IDs reused")}`, async () => {
|
||||
const customerId = "reuse-custom-pair";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-reuse-pair",
|
||||
items: [
|
||||
items.prepaidMessages({ includedUsage: 0, billingUnits: 100 }),
|
||||
items.consumableMessages({ includedUsage: 0, price: 0.5 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: proPlan.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 20 }),
|
||||
items: [
|
||||
itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }),
|
||||
itemsV2.consumableMessages({ amount: 0.5 }),
|
||||
itemsV2.dashboard(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
|
||||
|
||||
const { pairs } = await loadCustomerAndCatalogPrices({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId: proPlan.id,
|
||||
});
|
||||
|
||||
expectAllStripeIdsReused({ pairs });
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Plan versioning reuses the previous version's Stripe resources where possible.
|
||||
*
|
||||
* When a plan with existing customers is updated with a new items list, the
|
||||
* V1 update handler (handleUpdatePlanV1) auto-creates a new product version via
|
||||
* handleVersionProductV2. That path calls handleNewProductItems with
|
||||
* { curPrices: latestProduct.prices, newVersion: true }. The carry-forward
|
||||
* inside handleNewProductItems must copy the previous version's
|
||||
* `stripe_*_id` fields onto each new-version price whose config still matches.
|
||||
*
|
||||
* Contract under test:
|
||||
* - Versioning a plan with the same paid items (just adding a boolean entitlement)
|
||||
* keeps every paid price's Stripe IDs intact on the new version.
|
||||
* - Same for paid feature shapes (prepaid / consumable / allocated).
|
||||
* - Negative: versioning with a changed item (price amount, tier behavior,
|
||||
* billing_units) does NOT reuse stripe_price_id for that item.
|
||||
*
|
||||
* Implementation surface:
|
||||
* server/src/internal/products/handlers/handleVersionProduct.ts — versioning entry.
|
||||
* server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts — triggers versioning when customers exist + items differ.
|
||||
* server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts — calls carryForwardStripeResources.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiPlanItemV1,
|
||||
type CreatePlanItemParamsV1,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
type Price,
|
||||
priceStripeObjectsMatch,
|
||||
TierInfinite,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
const collectStripeIdsByFeatureKey = (
|
||||
prices: Price[],
|
||||
): Map<string, Record<string, string | null>> => {
|
||||
const map = new Map<string, Record<string, string | null>>();
|
||||
for (const price of prices) {
|
||||
const config = price.config as Record<string, unknown>;
|
||||
const featureId = (config.feature_id as string | undefined) ?? "__fixed__";
|
||||
const billWhen = (config.bill_when as string | undefined) ?? "__none__";
|
||||
const key = `${featureId}|${billWhen}`;
|
||||
map.set(key, {
|
||||
stripe_product_id: (config.stripe_product_id as string | null) ?? null,
|
||||
stripe_price_id: (config.stripe_price_id as string | null) ?? null,
|
||||
stripe_empty_price_id:
|
||||
(config.stripe_empty_price_id as string | null) ?? null,
|
||||
stripe_meter_id: (config.stripe_meter_id as string | null) ?? null,
|
||||
stripe_prepaid_price_v2_id:
|
||||
(config.stripe_prepaid_price_v2_id as string | null) ?? null,
|
||||
stripe_placeholder_price_id:
|
||||
(config.stripe_placeholder_price_id as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const findPriceForFeature = (
|
||||
prices: Price[],
|
||||
featureId: string,
|
||||
): Price | undefined =>
|
||||
prices.find(
|
||||
(price) =>
|
||||
(price.config as Record<string, unknown>).feature_id === featureId,
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: version a plan with same paid items + new boolean → all reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("versioning: add boolean entitlement → all paid Stripe IDs reused on new version")}`, async () => {
|
||||
const customerId = "reuse-version-add-bool";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-version-add-bool",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.prepaidUsers({ billingUnits: 1 }),
|
||||
items.consumableWords({ includedUsage: 0 }),
|
||||
items.allocatedWorkflows({ includedUsage: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: proPlan.id })],
|
||||
});
|
||||
|
||||
const beforeProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const beforeIds = collectStripeIdsByFeatureKey(beforeProduct.prices);
|
||||
|
||||
const updatedItems = [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.prepaidUsers({ billingUnits: 1 }),
|
||||
items.consumableWords({ includedUsage: 0 }),
|
||||
items.allocatedWorkflows({ includedUsage: 0 }),
|
||||
items.dashboard(),
|
||||
];
|
||||
|
||||
await autumnV1.products.update(proPlan.id, { items: updatedItems });
|
||||
|
||||
const afterProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
expect(afterProduct.version).toBe(beforeProduct.version + 1);
|
||||
|
||||
const afterIds = collectStripeIdsByFeatureKey(afterProduct.prices);
|
||||
|
||||
for (const [key, before] of beforeIds.entries()) {
|
||||
const after = afterIds.get(key);
|
||||
expect(after).toBeDefined();
|
||||
if (!after) continue;
|
||||
expect(before.stripe_price_id).not.toBeNull();
|
||||
expect(after.stripe_product_id).toBe(before.stripe_product_id);
|
||||
expect(after.stripe_price_id).toBe(before.stripe_price_id);
|
||||
expect(after.stripe_empty_price_id).toBe(before.stripe_empty_price_id);
|
||||
expect(after.stripe_meter_id).toBe(before.stripe_meter_id);
|
||||
expect(after.stripe_prepaid_price_v2_id).toBe(
|
||||
before.stripe_prepaid_price_v2_id,
|
||||
);
|
||||
expect(after.stripe_placeholder_price_id).toBe(
|
||||
before.stripe_placeholder_price_id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2 (negative): versioning with prepaid amount change → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("versioning: prepaid amount change → stripe_price_id NOT reused on new version")}`, async () => {
|
||||
const customerId = "reuse-version-amount-change";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-version-amount-change",
|
||||
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: proPlan.id })],
|
||||
});
|
||||
|
||||
const beforeProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const beforeMessages = findPriceForFeature(
|
||||
beforeProduct.prices,
|
||||
TestFeature.Messages,
|
||||
);
|
||||
expect(beforeMessages).toBeDefined();
|
||||
|
||||
const updatedItems = [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 25 }),
|
||||
];
|
||||
|
||||
await autumnV1.products.update(proPlan.id, { items: updatedItems });
|
||||
|
||||
const afterProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
expect(afterProduct.version).toBe(beforeProduct.version + 1);
|
||||
|
||||
const afterMessages = findPriceForFeature(
|
||||
afterProduct.prices,
|
||||
TestFeature.Messages,
|
||||
);
|
||||
expect(afterMessages).toBeDefined();
|
||||
if (!afterMessages || !beforeMessages) return;
|
||||
|
||||
const beforeConfig = beforeMessages.config as Record<string, unknown>;
|
||||
const afterConfig = afterMessages.config as Record<string, unknown>;
|
||||
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3 (negative): versioning with tier_behavior change → stripe_price_id not reused
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("versioning: graduated → volume tier_behavior → stripe_price_id NOT reused on new version")}`, async () => {
|
||||
const customerId = "reuse-version-tier-behavior";
|
||||
|
||||
const proPlan = products.pro({
|
||||
id: "pro-version-tier-behavior",
|
||||
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [proPlan] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: proPlan.id })],
|
||||
});
|
||||
|
||||
const beforeProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const beforeMessages = findPriceForFeature(
|
||||
beforeProduct.prices,
|
||||
TestFeature.Messages,
|
||||
);
|
||||
|
||||
const updatedItems = [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.volumePrepaidMessages({ includedUsage: 0 }),
|
||||
];
|
||||
|
||||
await autumnV1.products.update(proPlan.id, { items: updatedItems });
|
||||
|
||||
const afterProduct = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: proPlan.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
expect(afterProduct.version).toBe(beforeProduct.version + 1);
|
||||
|
||||
const afterMessages = findPriceForFeature(
|
||||
afterProduct.prices,
|
||||
TestFeature.Messages,
|
||||
);
|
||||
expect(afterMessages).toBeDefined();
|
||||
if (!afterMessages || !beforeMessages) return;
|
||||
|
||||
const beforeConfig = beforeMessages.config as Record<string, unknown>;
|
||||
const afterConfig = afterMessages.config as Record<string, unknown>;
|
||||
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect } from "bun:test";
|
||||
import { type AppEnv, type Price, prices } from "@autumn/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
const stripeIdFields = [
|
||||
"stripe_price_id",
|
||||
"stripe_product_id",
|
||||
"stripe_empty_price_id",
|
||||
"stripe_meter_id",
|
||||
"stripe_prepaid_price_v2_id",
|
||||
] as const;
|
||||
|
||||
const expectPriceHasNoStripeIds = (price: Price) => {
|
||||
const config = price.config as Record<string, unknown>;
|
||||
for (const field of stripeIdFields) {
|
||||
expect(config[field] ?? null).toBeNull();
|
||||
}
|
||||
};
|
||||
|
||||
export const expectNoStripeResources = async ({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
productId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
productId: string;
|
||||
}) => {
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(fullProduct.processor?.id ?? null).toBeNull();
|
||||
|
||||
for (const price of fullProduct.prices) {
|
||||
expectPriceHasNoStripeIds(price);
|
||||
}
|
||||
|
||||
const allPrices = (await db.query.prices.findMany({
|
||||
where: eq(prices.internal_product_id, fullProduct.internal_id),
|
||||
})) as Price[];
|
||||
|
||||
for (const price of allPrices) {
|
||||
expectPriceHasNoStripeIds(price);
|
||||
}
|
||||
};
|
||||
|
||||
export const expectNoCustomStripePrices = async ({
|
||||
db,
|
||||
internalProductId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
internalProductId: string;
|
||||
}) => {
|
||||
const customPrices = (await db.query.prices.findMany({
|
||||
where: eq(prices.internal_product_id, internalProductId),
|
||||
})) as Price[];
|
||||
|
||||
for (const price of customPrices) {
|
||||
if (!price.is_custom) continue;
|
||||
expectPriceHasNoStripeIds(price);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { expect } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
type FullCusProduct,
|
||||
type Price,
|
||||
type UsagePriceConfig,
|
||||
diffPriceStripeObjects,
|
||||
isFixedPrice,
|
||||
priceStripeObjectsMatch,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
|
||||
const priceFeatureId = (price: Price): string | null => {
|
||||
if (isFixedPrice(price)) return null;
|
||||
const config = price.config as UsagePriceConfig;
|
||||
return config.feature_id ?? null;
|
||||
};
|
||||
|
||||
const priceMatchKey = (price: Price): string => {
|
||||
if (isFixedPrice(price)) return "__fixed__";
|
||||
const featureId = priceFeatureId(price);
|
||||
const config = price.config as UsagePriceConfig;
|
||||
const billWhen = config.bill_when ?? "<missing>";
|
||||
return `feature:${featureId ?? "<missing>"}|bill_when:${billWhen}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a customer's primary FullCusProduct (custom or otherwise) to the
|
||||
* matching catalog plan's prices via feature_id (or "fixed" for base prices).
|
||||
* Returns matched pairs and the catalog plan for further assertions.
|
||||
*/
|
||||
export const loadCustomerAndCatalogPrices = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
catalogProductId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
catalogProductId: string;
|
||||
}): Promise<{
|
||||
catalogPrices: Price[];
|
||||
customerPrices: Price[];
|
||||
pairs: { catalog: Price; customer: Price }[];
|
||||
cusProduct: FullCusProduct;
|
||||
}> => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
|
||||
const cusProduct = fullCustomer.customer_products[0];
|
||||
if (!cusProduct) {
|
||||
throw new Error(`Customer ${customerId} has no customer_products`);
|
||||
}
|
||||
|
||||
const fullCatalog = await ProductService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: catalogProductId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const customerPrices = cusProduct.customer_prices.map(
|
||||
(customerPrice) => customerPrice.price,
|
||||
);
|
||||
|
||||
const pairs: { catalog: Price; customer: Price }[] = [];
|
||||
for (const customerPrice of customerPrices) {
|
||||
const key = priceMatchKey(customerPrice);
|
||||
const catalogMatch = fullCatalog.prices.find(
|
||||
(price) => priceMatchKey(price) === key,
|
||||
);
|
||||
if (!catalogMatch) continue;
|
||||
pairs.push({ catalog: catalogMatch, customer: customerPrice });
|
||||
}
|
||||
|
||||
return {
|
||||
catalogPrices: fullCatalog.prices,
|
||||
customerPrices,
|
||||
pairs,
|
||||
cusProduct,
|
||||
};
|
||||
};
|
||||
|
||||
const formatDiff = (catalog: Price, customer: Price): string => {
|
||||
const diffs = diffPriceStripeObjects({
|
||||
priceA: catalog,
|
||||
priceB: customer,
|
||||
});
|
||||
return diffs
|
||||
.map((diff) => `${diff.field}: catalog=${diff.a ?? "null"}, customer=${diff.b ?? "null"}`)
|
||||
.join("\n ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert every (catalog, customer) price pair shares all Stripe-object IDs.
|
||||
* Each catalog price must also have a non-null stripe_price_id so the
|
||||
* assertion is meaningful (verifies real reuse, not "both empty").
|
||||
*/
|
||||
export const expectAllStripeIdsReused = ({
|
||||
pairs,
|
||||
}: {
|
||||
pairs: { catalog: Price; customer: Price }[];
|
||||
}) => {
|
||||
expect(pairs.length).toBeGreaterThan(0);
|
||||
for (const { catalog, customer } of pairs) {
|
||||
const catalogConfig = catalog.config as Record<string, unknown>;
|
||||
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
const matches = priceStripeObjectsMatch({
|
||||
priceA: catalog,
|
||||
priceB: customer,
|
||||
});
|
||||
if (!matches) {
|
||||
throw new Error(
|
||||
`Expected stripe-object reuse for ${priceMatchKey(catalog)} but got diffs:\n ${formatDiff(catalog, customer)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that the customer price keyed by `featureId` (or fixed base when
|
||||
* `featureId` is null) does NOT reuse stripe_price_id from the catalog.
|
||||
* Both prices must have non-null stripe_price_id values for the assertion
|
||||
* to be meaningful. Falls back to feature-id-only matching when the strict
|
||||
* (feature + bill_when) pairing misses (e.g. prepaid → consumable swap).
|
||||
*/
|
||||
export const expectStripePriceIdNotReused = ({
|
||||
pairs,
|
||||
featureId,
|
||||
catalogPrices,
|
||||
customerPrices,
|
||||
}: {
|
||||
pairs: { catalog: Price; customer: Price }[];
|
||||
featureId: string | null;
|
||||
catalogPrices?: Price[];
|
||||
customerPrices?: Price[];
|
||||
}) => {
|
||||
let catalogPrice: Price | undefined;
|
||||
let customerPrice: Price | undefined;
|
||||
|
||||
if (featureId === null) {
|
||||
const pair = pairs.find(({ catalog }) => isFixedPrice(catalog));
|
||||
catalogPrice = pair?.catalog;
|
||||
customerPrice = pair?.customer;
|
||||
} else {
|
||||
const pair = pairs.find(
|
||||
({ catalog }) => priceFeatureId(catalog) === featureId,
|
||||
);
|
||||
if (pair) {
|
||||
catalogPrice = pair.catalog;
|
||||
customerPrice = pair.customer;
|
||||
} else if (catalogPrices && customerPrices) {
|
||||
catalogPrice = catalogPrices.find(
|
||||
(price) => priceFeatureId(price) === featureId,
|
||||
);
|
||||
customerPrice = customerPrices.find(
|
||||
(price) => priceFeatureId(price) === featureId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(catalogPrice).toBeDefined();
|
||||
expect(customerPrice).toBeDefined();
|
||||
if (!catalogPrice || !customerPrice) return;
|
||||
const catalogConfig = catalogPrice.config as Record<string, unknown>;
|
||||
const customerConfig = customerPrice.config as Record<string, unknown>;
|
||||
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(customerConfig.stripe_price_id ?? null).not.toBeNull();
|
||||
expect(customerConfig.stripe_price_id).not.toBe(
|
||||
catalogConfig.stripe_price_id,
|
||||
);
|
||||
};
|
||||
|
||||
export { priceMatchKey };
|
||||
|
||||
// Re-export for callers
|
||||
export type { DrizzleCli, AppEnv };
|
||||
@@ -52,6 +52,7 @@ export * from "./productUtils/priceUtils/index";
|
||||
// Price match utils
|
||||
export * from "./productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice";
|
||||
export * from "./productUtils/priceUtils/match/getPriceStripeReuseLevel";
|
||||
export * from "./productUtils/priceUtils/match/priceStripeObjectsMatch";
|
||||
export * from "./productV2Utils/mapToProductV2";
|
||||
export * from "./productV2Utils/productItemUtils/classifyItemUtils";
|
||||
export * from "./productV2Utils/productItemUtils/getItemType";
|
||||
|
||||
@@ -36,7 +36,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
|
||||
ent1.allowance_type !== AllowanceType.Unlimited &&
|
||||
ent1.allowance != ent2.allowance,
|
||||
carryFromPrevious: ent1.carry_from_previous != ent2.carry_from_previous,
|
||||
entityFeatureId: ent1.entity_feature_id !== ent2.entity_feature_id,
|
||||
entityFeatureId: ent1.entity_feature_id != ent2.entity_feature_id,
|
||||
usageLimit: ent1.usage_limit != ent2.usage_limit,
|
||||
rollover: !rolloversAreSame({
|
||||
rollover1: ent1.rollover,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const copyStripeResourcesToMatchingPrice = ({
|
||||
candidateEntitlements,
|
||||
}: {
|
||||
targetPrice: Price;
|
||||
// Stripe IDs are copied FROM the best-matching candidate TO targetPrice.
|
||||
candidatePrices: Price[];
|
||||
targetEntitlements: Entitlement[];
|
||||
candidateEntitlements: Entitlement[];
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Price } from "@autumn/shared";
|
||||
|
||||
const stripeResourceFields = [
|
||||
"stripe_product_id",
|
||||
"stripe_price_id",
|
||||
"stripe_empty_price_id",
|
||||
"stripe_placeholder_price_id",
|
||||
"stripe_prepaid_price_v2_id",
|
||||
"stripe_meter_id",
|
||||
"stripe_event_name",
|
||||
] as const;
|
||||
|
||||
export type PriceStripeObjectField = (typeof stripeResourceFields)[number];
|
||||
|
||||
const readField = (price: Price, field: PriceStripeObjectField): string | null => {
|
||||
const config = price.config as Partial<Record<PriceStripeObjectField, string | null>>;
|
||||
return config[field] ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True iff every Stripe-resource field that initStripeResourcesForBillingPlan
|
||||
* cares about (`stripe_product_id`, `stripe_price_id`, `stripe_empty_price_id`,
|
||||
* `stripe_placeholder_price_id`, `stripe_prepaid_price_v2_id`,
|
||||
* `stripe_meter_id`, `stripe_event_name`) is identical between the two prices.
|
||||
*
|
||||
* Used by stripe-reuse coverage to assert that a versioned / custom price
|
||||
* carried the original plan's Stripe resources forward instead of minting
|
||||
* fresh ones.
|
||||
*/
|
||||
export const priceStripeObjectsMatch = ({
|
||||
priceA,
|
||||
priceB,
|
||||
}: {
|
||||
priceA: Price;
|
||||
priceB: Price;
|
||||
}): boolean => {
|
||||
for (const field of stripeResourceFields) {
|
||||
if (readField(priceA, field) !== readField(priceB, field)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of Stripe-resource fields whose values differ between
|
||||
* `priceA` and `priceB`. Useful for surfacing why a reuse assertion failed.
|
||||
*/
|
||||
export const diffPriceStripeObjects = ({
|
||||
priceA,
|
||||
priceB,
|
||||
}: {
|
||||
priceA: Price;
|
||||
priceB: Price;
|
||||
}): {
|
||||
field: PriceStripeObjectField;
|
||||
a: string | null;
|
||||
b: string | null;
|
||||
}[] => {
|
||||
const diffs: {
|
||||
field: PriceStripeObjectField;
|
||||
a: string | null;
|
||||
b: string | null;
|
||||
}[] = [];
|
||||
for (const field of stripeResourceFields) {
|
||||
const a = readField(priceA, field);
|
||||
const b = readField(priceB, field);
|
||||
if (a !== b) diffs.push({ field, a, b });
|
||||
}
|
||||
return diffs;
|
||||
};
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { useAdmin } from "@/views/admin/hooks/useAdmin";
|
||||
import { AdminHover } from "@/components/general/AdminHover";
|
||||
|
||||
export type AdminPlanIds = {
|
||||
stripe_price_id?: string | null;
|
||||
@@ -12,22 +7,6 @@ export type AdminPlanIds = {
|
||||
internal_product_id?: string | null;
|
||||
};
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string | null | undefined }) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-t4">
|
||||
{label}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-t1 break-all">{value}</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a child with an admin-only hover tooltip showing identifying IDs
|
||||
* for the displayed plan/price. No-op for non-admin users.
|
||||
*/
|
||||
export const AdminPlanIdsTooltip = ({
|
||||
children,
|
||||
ids,
|
||||
@@ -35,28 +14,22 @@ export const AdminPlanIdsTooltip = ({
|
||||
children: ReactNode;
|
||||
ids: AdminPlanIds;
|
||||
}) => {
|
||||
const { isAdmin } = useAdmin();
|
||||
const texts = [
|
||||
ids.stripe_price_id && {
|
||||
key: "Stripe price id",
|
||||
value: ids.stripe_price_id,
|
||||
},
|
||||
ids.stripe_product_id && {
|
||||
key: "Stripe product id",
|
||||
value: ids.stripe_product_id,
|
||||
},
|
||||
ids.internal_product_id && {
|
||||
key: "Autumn internal id",
|
||||
value: ids.internal_product_id,
|
||||
},
|
||||
].filter(Boolean) as { key: string; value: string }[];
|
||||
|
||||
const hasAnyId = Boolean(
|
||||
ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id,
|
||||
);
|
||||
if (texts.length === 0) return <>{children}</>;
|
||||
|
||||
if (!isAdmin || !hasAnyId) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="flex flex-col gap-2 max-w-sm"
|
||||
>
|
||||
<Row label="Stripe price id" value={ids.stripe_price_id} />
|
||||
<Row label="Stripe product id" value={ids.stripe_product_id} />
|
||||
<Row label="Autumn internal id" value={ids.internal_product_id} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return <AdminHover texts={texts}>{children}</AdminHover>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user