diff --git a/server/src/internal/product/actions/createProduct.ts b/server/src/internal/product/actions/createProduct.ts index 943749a49..2fe40c00b 100644 --- a/server/src/internal/product/actions/createProduct.ts +++ b/server/src/internal/product/actions/createProduct.ts @@ -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, diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index f6ead34c8..674ef69fb 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -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, diff --git a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts index 2d07507f9..f9fb23fd0 100644 --- a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts +++ b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts @@ -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, }); }; diff --git a/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts new file mode 100644 index 000000000..11a02035e --- /dev/null +++ b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts @@ -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, + }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts new file mode 100644 index 000000000..1d6c916cf --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts @@ -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(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(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(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(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(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(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(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(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(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(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(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts new file mode 100644 index 000000000..262382de7 --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts @@ -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> => { + const map = new Map>(); + for (const price of prices) { + const config = price.config as Record; + 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).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; + const afterConfig = afterMessages.config as Record; + 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; + const afterConfig = afterMessages.config as Record; + 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); +}); diff --git a/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts new file mode 100644 index 000000000..85c674dbc --- /dev/null +++ b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts @@ -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; + 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); + } +}; diff --git a/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts new file mode 100644 index 000000000..379e4c7a0 --- /dev/null +++ b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts @@ -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 ?? ""; + return `feature:${featureId ?? ""}|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; + 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; + const customerConfig = customerPrice.config as Record; + 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 }; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 53b5d9970..23d5d0423 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -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"; diff --git a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts index 66eae1c8a..06c569cdf 100644 --- a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts +++ b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts @@ -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, diff --git a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts index 0677c560c..de5923cc6 100644 --- a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts +++ b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts @@ -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[]; diff --git a/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts new file mode 100644 index 000000000..3e7ab54f9 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts @@ -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>; + 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; +}; diff --git a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx index 3076347b5..9fc9ea4cc 100644 --- a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx +++ b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx @@ -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 ( -
- - {label} - - {value} -
- ); -}; - -/** - * 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 ( - - {children} - - - - - - - ); + return {children}; };