From 972b5f601953b229c6d551b8eed1f1845064cda3 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 8 May 2026 01:25:53 +0800 Subject: [PATCH] latest --- .agents/skills/write-test/SKILL.md | 27 + .../computeScheduledCustomerProducts.ts | 1 + .../actions/createSchedule/createSchedule.ts | 2 +- .../errors/handleCreateScheduleErrors.ts | 29 +- .../createSchedule/previewCreateSchedule.ts | 3 +- .../setupCreateScheduleBillingContext.ts | 1 + .../setup/setupScheduledProductsContext.ts | 1 + .../compute/customPlan/computeCustomPlan.ts | 8 + .../errors/handleCustomPlanErrors.ts | 4 +- .../logs/logUpdateSubscriptionContext.ts | 3 + .../setupUpdateSubscriptionBillingContext.ts | 2 + .../setupUpdateSubscriptionProductContext.ts | 16 +- .../computePatchCustomerProductPlan.ts | 112 +++++ .../v2/compute/computePatchPlan/index.ts | 1 + .../executePatchCustomerProducts.ts | 90 ++++ .../v2/execute/executeAutumnBillingPlan.ts | 10 + .../common/initStripeResourcesForProducts.ts | 16 +- .../v2/setup/patch/handleCustomizeAddItems.ts | 52 ++ .../setup/patch/handleCustomizeDeleteItems.ts | 152 ++++++ .../v2/setup/patch/handleCustomizePrice.ts | 80 +++ .../internal/billing/v2/setup/patch/index.ts | 3 + .../v2/setup/patch/setupPatchContext.ts | 160 ++++++ .../v2/setup/setupCustomFullProduct.ts | 5 +- .../autumnBillingPlanToFinalFullCustomer.ts | 54 +- .../customerProductPlanMutations.ts | 22 + .../billingPlanToNextCyclePreview.ts | 56 ++- .../duplicateCustomerProduct.ts | 60 +++ .../duplicateCustomerProduct/index.ts | 1 + .../initCustomerProduct.ts | 3 +- .../applyCustomerProductItemsPatch.ts | 45 ++ .../getPatchCarryCustomerProduct.ts | 29 ++ .../initPatchedCustomerProduct/index.ts | 4 + .../initPatchCustomerProduct.ts | 56 +++ ...nitPatchedCustomerEntitlementsAndPrices.ts | 110 +++++ .../initScheduledCustomerProduct.ts | 4 + .../v2/utils/logs/logAutumnBillingPlan.ts | 18 + .../add-product/createFullCusProduct.ts | 8 +- .../cusProducts/CusProductService.ts | 8 +- server/tests/_groups/temp.ts | 14 +- .../create-schedule-basic.test.ts | 20 - .../create-schedule-subscription-id.test.ts | 169 +++++++ .../patch-update-items-carry-rollover.test.ts | 235 +++++++++ .../patch-update-items-carry-usage.test.ts | 178 +++++++ .../patch-update-items.test.ts | 256 ++++++++++ .../patch-update-paid-features.test.ts | 393 +++++++++++++++ .../patch-update-price.test.ts | 236 +++++++++ .../patch-update-with-others.test.ts | 429 ++++++++++++++++ .../utils/expectNoExpiredCustomerProducts.ts | 40 ++ .../utils/fullSubjectScenarioBuilders.ts | 1 + .../redis/create-redis-availability.test.ts} | 0 .../integration/utils/expectFlagCorrect.ts | 11 +- .../create-schedule-params.spec.ts | 37 +- .../handle-create-schedule-errors.spec.ts | 37 +- .../utils/fixtures/db/customerProducts.ts | 1 + .../common/customizePlan/customizePlanV1.ts | 42 +- .../createSchedule/createScheduleParamsV0.ts | 6 +- .../products/items/filter/planItemFilter.ts | 31 ++ shared/api/products/items/index.ts | 3 + .../items/mappers/planItemV1ToPriceAndEnt.ts | 34 ++ .../api/products/items/utils/match/index.ts | 1 + .../planItemFilterMatchesCustomerPair.ts | 57 +++ .../context/createScheduleBillingContext.ts | 2 + .../updateSubscriptionBillingContext.ts | 17 + .../billingModels/plan/autumnBillingPlan.ts | 13 + .../cusProductModels/cusProductModels.ts | 1 + .../cusProductModels/cusProductTable.ts | 1 + shared/utils/index.ts | 1 + .../entUtils/compareEnt/entsAreSame.ts | 48 ++ shared/utils/productUtils/entUtils/index.ts | 1 + .../priceUtils/comparePrice/pricesAreSame.ts | 86 ++++ shared/utils/productUtils/priceUtils/index.ts | 1 + .../productItemUtils/classifyItemUtils.ts | 23 + .../mappers/itemToPriceAndEnt.ts | 460 ++++++++++++++++++ .../productItemUtils/matchPlanItem.ts | 38 ++ 74 files changed, 4066 insertions(+), 113 deletions(-) create mode 100644 server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts create mode 100644 server/src/internal/billing/v2/compute/computePatchPlan/index.ts create mode 100644 server/src/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts.ts create mode 100644 server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts create mode 100644 server/src/internal/billing/v2/setup/patch/handleCustomizeDeleteItems.ts create mode 100644 server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts create mode 100644 server/src/internal/billing/v2/setup/patch/index.ts create mode 100644 server/src/internal/billing/v2/setup/patch/setupPatchContext.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/duplicateCustomerProduct.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/index.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/applyCustomerProductItemsPatch.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts create mode 100644 server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-rollover.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-usage.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-paid-features.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-price.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-with-others.test.ts create mode 100644 server/tests/integration/billing/utils/expectNoExpiredCustomerProducts.ts rename server/tests/{unit/redis/create-redis-availability.spec.ts => integration/others/redis/create-redis-availability.test.ts} (100%) create mode 100644 shared/api/products/items/filter/planItemFilter.ts create mode 100644 shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts create mode 100644 shared/api/products/items/utils/match/index.ts create mode 100644 shared/api/products/items/utils/match/planItemFilterMatchesCustomerPair.ts create mode 100644 shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts create mode 100644 shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts diff --git a/.agents/skills/write-test/SKILL.md b/.agents/skills/write-test/SKILL.md index bb5907e11..b811cf5c1 100644 --- a/.agents/skills/write-test/SKILL.md +++ b/.agents/skills/write-test/SKILL.md @@ -16,7 +16,9 @@ description: Write integration tests for Autumn billing. Covers initScenario set import { expect, test } from "bun:test"; import { type ApiCustomerV5, type AttachParamsV1Input } from "@autumn/shared"; import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -43,6 +45,7 @@ test.concurrent(`${chalk.yellowBright("feature: description")}`, async () => { await autumnV2_2.billing.attach(params); const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); expectBalanceCorrect({ customer, featureId: TestFeature.Messages, @@ -167,6 +170,8 @@ await expectProductScheduled({ customer, productId: free.id }); await expectProductNotPresent({ customer, productId: pro.id }); ``` +For new billing tests, prefer fetching with `autumnV2_2.customers.get()` and passing that response to `expectCustomerProducts`. + ### Balances ```typescript @@ -181,6 +186,28 @@ expectBalanceCorrect({ }); ``` +Use `expectBalanceCorrect` for metered features in V2.2 responses instead of reading `customer.features`. + +### Flags + +```typescript +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; + +expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, +}); + +expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + present: false, +}); +``` + +Use `expectFlagCorrect` for boolean features in V2.2 customer/entity responses instead of reading `customer.features`. + ### Features ```typescript diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts index 1584b070e..469c42f2d 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts @@ -33,6 +33,7 @@ export const computeScheduledCustomerProducts = ({ startsAt: phaseContext.startsAt, endsAt: phaseContext.endsAt, currentEpochMs: billingContext.currentEpochMs, + externalId: productContext.externalId, }); insertCustomerProducts.push(customerProduct); phaseCustomerProductIds.push(customerProduct.id); diff --git a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts index 4c7ab9a06..99b4d43c7 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts @@ -56,7 +56,7 @@ export const createSchedule = async ({ params, }); - handleCreateScheduleErrors({ billingContext }); + await handleCreateScheduleErrors({ db: ctx.db, billingContext }); const { autumnBillingPlan, phases } = computeCreateSchedulePlan({ ctx, diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts index 8d75e5587..d4be0e019 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts @@ -4,12 +4,16 @@ import { ms, RecaseError, } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { handleSubscriptionIdErrors } from "@/internal/billing/v2/common/errors/handleSubscriptionIdErrors"; const FIRST_PHASE_TOLERANCE_MS = ms.minutes(15); -export const handleCreateScheduleErrors = ({ +export const handleCreateScheduleErrors = async ({ + db, billingContext, }: { + db: DrizzleCli; billingContext: CreateScheduleBillingContext; }) => { const { currentEpochMs, immediatePhase, stripeSubscriptionSchedule } = @@ -32,15 +36,30 @@ export const handleCreateScheduleErrors = ({ // (see executeStripeSubscriptionScheduleAction.buildAnchoredPhases), so the // caller-supplied starts_at for phase 0 is effectively ignored. The // immediate-start guard only makes sense on creation. - if (stripeSubscriptionSchedule) return; - if ( - immediatePhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS || - immediatePhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS + !stripeSubscriptionSchedule && + (immediatePhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS || + immediatePhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS) ) { throw new RecaseError({ message: "The first phase must start immediately", statusCode: 400, }); } + + const immediateSubscriptionIds = billingContext.productContexts.map( + (productContext) => productContext.externalId, + ); + const scheduledSubscriptionIds = billingContext.scheduledPhaseContexts.flatMap( + (phaseContext) => + phaseContext.productContexts.map( + (productContext) => productContext.externalId, + ), + ); + + await handleSubscriptionIdErrors({ + db, + internalCustomerId: billingContext.fullCustomer.internal_id, + subscriptionIds: [...immediateSubscriptionIds, ...scheduledSubscriptionIds], + }); }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index dfadaeeb2..d219c1ca0 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -29,7 +29,8 @@ export const previewCreateScheduleWithContext = async ({ params, }); - handleCreateScheduleErrors({ + await handleCreateScheduleErrors({ + db: ctx.db, billingContext, }); diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index c2e41c980..e71a1c5e5 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -87,6 +87,7 @@ export const setupCreateScheduleBillingContext = async ({ customize: plan.customize, feature_quantities: plan.feature_quantities, version: plan.version, + subscription_id: plan.subscription_id, })), invoice_mode: params.invoice_mode, success_url: params.success_url, diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts index 3eb4027c3..769e5536c 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts @@ -44,6 +44,7 @@ export const setupScheduledProductsContext = async ({ customPrices, customEntitlements, featureQuantities, + externalId: plan.subscription_id, }; }), ); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index 7e3d1b440..82d9a46f9 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -8,6 +8,7 @@ import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/computeDeleteCustomerProduct"; import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import { computePatchCustomerProductPlan } from "@/internal/billing/v2/compute/computePatchPlan"; export const computeCustomPlan = async ({ ctx, @@ -18,6 +19,13 @@ export const computeCustomPlan = async ({ params: UpdateSubscriptionV1Params; updateSubscriptionContext: UpdateSubscriptionBillingContext; }) => { + if (updateSubscriptionContext.patchContext) { + return computePatchCustomerProductPlan({ + ctx, + updateSubscriptionContext, + }); + } + const { customerProduct, customPrices, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts index bf2fc8eb0..8e5d21617 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts @@ -24,7 +24,9 @@ export const handleCustomPlanErrors = ({ }) => { if (!hasCustomItems(params.customize)) return; - const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; + const newCustomerProduct = + billingContext.patchContext?.finalCustomerProduct ?? + autumnBillingPlan.insertCustomerProducts?.[0]; const currentCustomerProduct = billingContext.customerProduct; const currentFullProduct = cusProductToProduct({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts index 00024a532..ed3b9d8dc 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts @@ -57,6 +57,9 @@ export const logUpdateSubscriptionContext = ({ defaultProduct: billingContext.defaultProduct?.name ?? "undefined", cancelAction: cancelAction ? cancelAction : "no cancel operation", skipBillingChanges: billingContext.skipBillingChanges, + patchContext: billingContext.patchContext + ? `${billingContext.patchContext.mode} ${billingContext.patchContext.originalCustomerProduct.id} -> ${billingContext.patchContext.finalCustomerProduct.id} | +${billingContext.patchContext.customEntitlements.length} ent, +${billingContext.patchContext.customPrices.length} price | -${billingContext.patchContext.deleteCustomerEntitlements.length} ent, -${billingContext.patchContext.deleteCustomerPrices.length} price` + : "none", anchorResetRefund: billingContext.anchorResetRefund ? `noPartialRefund: ${billingContext.anchorResetRefund.noPartialRefund} | refundCycle: ${billingContext.anchorResetRefund.refundCycle ? `${billingContext.anchorResetRefund.refundCycle.interval} x${billingContext.anchorResetRefund.refundCycle.intervalCount}` : "none"}` diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index c58715a09..8e168cd45 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -54,6 +54,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ const { customerProduct, fullProduct, + patchContext, customPrices, customEnts, isUpdatingFreeCustomerProduct, @@ -175,6 +176,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ fullCustomer, fullProducts: [fullProduct], customerProduct, + patchContext, defaultProduct, cancelAction, recalculateBalances: params.recalculate_balances?.enabled === true, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index 19ebc44bd..afe66f099 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -8,6 +8,7 @@ import { type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupPatchContext } from "@/internal/billing/v2/setup/patch"; import { ProductService } from "@/internal/products/ProductService"; import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct"; import { findTargetCustomerProduct } from "./findTargetCustomerProduct"; @@ -57,6 +58,13 @@ export const setupUpdateSubscriptionProductContext = async ({ }); } + const patchContext = setupPatchContext({ + ctx, + params, + customerProduct: targetCustomerProduct, + fullProduct, + }); + const { fullProduct: customFullProduct, customPrices, @@ -65,15 +73,19 @@ export const setupUpdateSubscriptionProductContext = async ({ ctx, currentFullProduct: fullProduct, customizePlan: params.customize, + patchContext, }); + const finalFullProduct = patchContext?.fullProduct ?? customFullProduct; + const isUpdatingFreeCustomerProduct = isCustomerProductFree(targetCustomerProduct) && - isFreeProduct({ prices: customFullProduct.prices }); + isFreeProduct({ prices: finalFullProduct.prices }); return { customerProduct: targetCustomerProduct, - fullProduct: customFullProduct, + fullProduct: finalFullProduct, + patchContext, customPrices, customEnts, isUpdatingFreeCustomerProduct, diff --git a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts new file mode 100644 index 000000000..c8a7efe5c --- /dev/null +++ b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts @@ -0,0 +1,112 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type UpdateSubscriptionBillingContext, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import { initPatchCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct"; + +const getPatchTrialUpdates = ({ + updateSubscriptionContext, +}: { + updateSubscriptionContext: UpdateSubscriptionBillingContext; +}) => { + const { trialContext } = updateSubscriptionContext; + + if (!trialContext) return {}; + + if (trialContext.customFreeTrial) { + return { + free_trial_id: trialContext.customFreeTrial.id, + trial_ends_at: trialContext.trialEndsAt ?? null, + }; + } + + if (trialContext.trialEndsAt === null) { + return { + free_trial_id: null, + trial_ends_at: null, + }; + } + + return {}; +}; + +export const computePatchCustomerProductPlan = ({ + ctx, + updateSubscriptionContext, +}: { + ctx: AutumnContext; + updateSubscriptionContext: UpdateSubscriptionBillingContext; +}): AutumnBillingPlan => { + const { fullCustomer, patchContext, trialContext } = + updateSubscriptionContext; + + if (!patchContext) { + throw new Error("Patch context is required to compute patch customer plan"); + } + + const finalCustomerProduct = initPatchCustomerProduct({ + ctx, + billingContext: updateSubscriptionContext, + patchContext, + }); + + const { allLineItems } = buildAutumnLineItems({ + ctx, + newCustomerProducts: [finalCustomerProduct], + deletedCustomerProduct: patchContext.originalCustomerProduct, + billingContext: updateSubscriptionContext, + includeArrearLineItems: + updateSubscriptionContext.chargeExistingOverages === true, + }); + + const basePlan = { + customerId: fullCustomer?.id ?? "", + customPrices: patchContext.customPrices, + customEntitlements: patchContext.customEntitlements, + customFreeTrial: trialContext?.customFreeTrial, + lineItems: allLineItems, + } satisfies Partial; + + if (patchContext.mode === "new") { + return { + ...basePlan, + insertCustomerProducts: [finalCustomerProduct], + updateCustomerProduct: { + customerProduct: patchContext.originalCustomerProduct, + updates: { + status: CusProductStatus.Expired, + ended_at: Date.now(), + canceled: true, + canceled_at: Date.now(), + }, + }, + } satisfies AutumnBillingPlan; + } + + return { + ...basePlan, + insertCustomerProducts: [], + updateCustomerProducts: [ + { + customerProduct: patchContext.originalCustomerProduct, + updates: { + options: finalCustomerProduct.options, + updated_at: Date.now(), + ...getPatchTrialUpdates({ updateSubscriptionContext }), + }, + }, + ], + patchCustomerProducts: [ + { + customerProduct: patchContext.originalCustomerProduct, + insertCustomerPrices: patchContext.insertCustomerPrices, + insertCustomerEntitlements: patchContext.insertCustomerEntitlements, + deleteCustomerPrices: patchContext.deleteCustomerPrices, + deleteCustomerEntitlements: patchContext.deleteCustomerEntitlements, + }, + ], + } satisfies AutumnBillingPlan; +}; diff --git a/server/src/internal/billing/v2/compute/computePatchPlan/index.ts b/server/src/internal/billing/v2/compute/computePatchPlan/index.ts new file mode 100644 index 000000000..65b4808fd --- /dev/null +++ b/server/src/internal/billing/v2/compute/computePatchPlan/index.ts @@ -0,0 +1 @@ +export * from "./computePatchCustomerProductPlan"; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts.ts new file mode 100644 index 000000000..b9a8bd0c3 --- /dev/null +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts.ts @@ -0,0 +1,90 @@ +import { + customerProductHasActiveStatus, + type FullCusProduct, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService"; +import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService"; +import { applyCustomerProductPatch } from "../../utils/billingPlan/customerProductPlanMutations"; + +export const executePatchCustomerProducts = async ({ + ctx, + patchCustomerProducts, +}: { + ctx: AutumnContext; + patchCustomerProducts: NonNullable< + import("@autumn/shared").AutumnBillingPlan["patchCustomerProducts"] + >; +}) => { + for (const patchCustomerProduct of patchCustomerProducts) { + await CusEntService.insert({ + ctx, + data: patchCustomerProduct.insertCustomerEntitlements, + }); + + await CusPriceService.insert({ + db: ctx.db, + data: patchCustomerProduct.insertCustomerPrices, + }); + + const finalCustomerProduct = applyCustomerProductPatch({ + customerProduct: patchCustomerProduct.customerProduct, + patch: patchCustomerProduct, + }); + + await insertPatchRollovers({ + ctx, + customerProduct: finalCustomerProduct, + customerEntitlements: patchCustomerProduct.insertCustomerEntitlements, + }); + + for (const customerPrice of patchCustomerProduct.deleteCustomerPrices) { + await CusPriceService.delete({ + db: ctx.db, + id: customerPrice.id, + }); + } + + for (const customerEntitlement of patchCustomerProduct.deleteCustomerEntitlements) { + await CusEntService.delete({ + db: ctx.db, + id: customerEntitlement.id, + }); + } + } +}; + +const insertPatchRollovers = async ({ + ctx, + customerProduct, + customerEntitlements, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + customerEntitlements: NonNullable< + import("@autumn/shared").AutumnBillingPlan["patchCustomerProducts"] + >[number]["insertCustomerEntitlements"]; +}) => { + if (!customerProductHasActiveStatus(customerProduct)) return; + + const rolloverInsertPromises = customerEntitlements.flatMap( + (customerEntitlement) => { + if (customerEntitlement.rollovers.length === 0) return []; + + return [ + RolloverService.insert({ + ctx, + rows: customerEntitlement.rollovers, + fullCusEnt: { + ...customerEntitlement, + customer_product: customerProduct, + rollovers: [], + }, + }), + ]; + }, + ); + + await Promise.all(rolloverInsertPromises); +}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index bf5976a63..780890629 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -2,6 +2,7 @@ import type { AutumnBillingPlan, Invoice } from "@autumn/shared"; import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { executeAutoTopupRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance"; +import { executePatchCustomerProducts } from "@/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts"; import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts"; import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements"; import { @@ -73,6 +74,15 @@ export const executeAutumnBillingPlan = async ({ }); } + if (autumnBillingPlan.patchCustomerProducts) { + // Custom prices/entitlements above must be inserted before customer rows can reference them. + // Patch execution only inserts/deletes customer_prices and customer_entitlements. + await executePatchCustomerProducts({ + ctx, + patchCustomerProducts: autumnBillingPlan.patchCustomerProducts, + }); + } + // ctx.logger.debug( // `[execAutumnPlan] inserting new customer products: ${insertCustomerProducts.map((cp) => cp.product.id).join(", ")}`, // ); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index 1a96fedd1..705411219 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -7,6 +7,10 @@ import { } from "@autumn/shared"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { + applyCustomerProductPatch, + getPatchCustomerProducts, +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { checkStripeProductExists } from "@/internal/products/productUtils"; export const initStripeResourcesForBillingPlan = async ({ @@ -27,6 +31,16 @@ export const initStripeResourcesForBillingPlan = async ({ cusProductToProduct({ cusProduct: cp }), ); + const patchProducts = getPatchCustomerProducts({ autumnBillingPlan }).map( + (patchCustomerProduct) => + cusProductToProduct({ + cusProduct: applyCustomerProductPatch({ + customerProduct: patchCustomerProduct.customerProduct, + patch: patchCustomerProduct, + }), + }), + ); + const existingProducts = fullCustomer.customer_products .map((customerProduct) => cusProductToProduct({ cusProduct: customerProduct }), @@ -44,7 +58,7 @@ export const initStripeResourcesForBillingPlan = async ({ (product) => nullish(product.processor?.id) || product.prices.length > 0, ); - const allProducts = [...newProducts, ...existingProducts]; + const allProducts = [...newProducts, ...patchProducts, ...existingProducts]; const batchProductUpdates = []; for (const product of allProducts) { diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts new file mode 100644 index 000000000..99a0798e3 --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeAddItems.ts @@ -0,0 +1,52 @@ +import type { + CustomizePlanV1, + Entitlement, + EntitlementWithFeature, + FullProduct, + Price, + SharedContext, +} from "@autumn/shared"; +import { planItemV1ToPriceAndEnt } from "@shared/api/products/items/mappers/planItemV1ToPriceAndEnt"; + +export const handleCustomizeAddItems = ({ + ctx, + customize, + fullProduct, +}: { + ctx: SharedContext; + customize: CustomizePlanV1; + fullProduct: FullProduct; +}): { + prices: Price[]; + entitlements: Entitlement[]; +} => { + const prices: Price[] = []; + const entitlements: Entitlement[] = []; + + for (const item of customize.add_items ?? []) { + const { newPrice, newEnt } = planItemV1ToPriceAndEnt({ + ctx, + item, + orgId: fullProduct.org_id, + internalProductId: fullProduct.internal_id, + isCustom: true, + }); + + if (newPrice) prices.push(newPrice); + if (newEnt) entitlements.push(newEnt); + } + + const entitlementsWithFeatures: EntitlementWithFeature[] = entitlements.map( + (entitlement) => ({ + ...entitlement, + feature: ctx.features.find( + (feature) => feature.internal_id === entitlement.internal_feature_id, + )!, + }), + ); + + fullProduct.prices.push(...prices); + fullProduct.entitlements.push(...entitlementsWithFeatures); + + return { prices, entitlements }; +}; diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeDeleteItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeDeleteItems.ts new file mode 100644 index 000000000..605f892e0 --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeDeleteItems.ts @@ -0,0 +1,152 @@ +import type { + CustomizePlanV1, + FullCusEntWithFullCusProduct, + FullCusProduct, + FullCustomerEntitlement, + FullCustomerPrice, +} from "@autumn/shared"; +import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match"; +import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; +import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement"; + +type CustomerProductItemPair = { + customerPrice?: FullCustomerPrice; + customerEntitlement?: FullCustomerEntitlement; +}; + +const getCustomerProductItemPairs = ({ + targetCustomerProduct, +}: { + targetCustomerProduct: FullCusProduct; +}): CustomerProductItemPair[] => { + const pairs: CustomerProductItemPair[] = + targetCustomerProduct.customer_prices.map((customerPrice) => ({ + customerPrice, + customerEntitlement: customerPriceToCustomerEntitlement({ + customerPrice, + customerEntitlements: targetCustomerProduct.customer_entitlements, + }), + })); + + for (const customerEntitlement of targetCustomerProduct.customer_entitlements) { + const customerPrice = cusEntToCusPrice({ + cusEnt: { + ...customerEntitlement, + customer_product: targetCustomerProduct, + } satisfies FullCusEntWithFullCusProduct, + }); + + if (!customerPrice) { + pairs.push({ customerEntitlement }); + } + } + + return pairs; +}; + +const getCustomerProductItemsToDelete = ({ + pairsToDelete, + targetCustomerProduct, +}: { + pairsToDelete: CustomerProductItemPair[]; + targetCustomerProduct: FullCusProduct; +}): { + customerPrices: FullCustomerPrice[]; + customerEntitlements: FullCustomerEntitlement[]; +} => { + const customerPrices = new Map(); + const customerEntitlements = new Map(); + const entitlementIdsToDelete = new Set(); + + for (const pair of pairsToDelete) { + if (pair.customerPrice) { + customerPrices.set(pair.customerPrice.id, pair.customerPrice); + } + + if (pair.customerEntitlement) { + customerEntitlements.set( + pair.customerEntitlement.id, + pair.customerEntitlement, + ); + entitlementIdsToDelete.add(pair.customerEntitlement.entitlement.id); + } + } + + for (const customerPrice of targetCustomerProduct.customer_prices) { + if ( + customerPrice.price.entitlement_id && + entitlementIdsToDelete.has(customerPrice.price.entitlement_id) + ) { + customerPrices.set(customerPrice.id, customerPrice); + } + } + + return { + customerPrices: Array.from(customerPrices.values()), + customerEntitlements: Array.from(customerEntitlements.values()), + }; +}; + +const deleteCustomerProductItems = ({ + pairsToDelete, + targetCustomerProduct, +}: { + pairsToDelete: CustomerProductItemPair[]; + targetCustomerProduct: FullCusProduct; +}): { + customerPrices: FullCustomerPrice[]; + customerEntitlements: FullCustomerEntitlement[]; +} => { + const { customerPrices, customerEntitlements } = + getCustomerProductItemsToDelete({ + pairsToDelete, + targetCustomerProduct, + }); + + const customerPriceIdsToDelete = new Set( + customerPrices.map((customerPrice) => customerPrice.id), + ); + const customerEntitlementIdsToDelete = new Set( + customerEntitlements.map((customerEntitlement) => customerEntitlement.id), + ); + + targetCustomerProduct.customer_prices = + targetCustomerProduct.customer_prices.filter( + (customerPrice) => !customerPriceIdsToDelete.has(customerPrice.id), + ); + targetCustomerProduct.customer_entitlements = + targetCustomerProduct.customer_entitlements.filter( + (customerEntitlement) => + !customerEntitlementIdsToDelete.has(customerEntitlement.id), + ); + + return { customerPrices, customerEntitlements }; +}; + +export const handleCustomizeDeleteItems = ({ + customize, + targetCustomerProduct, +}: { + customize: CustomizePlanV1; + targetCustomerProduct: FullCusProduct; +}): { + customerPrices: FullCustomerPrice[]; + customerEntitlements: FullCustomerEntitlement[]; +} => { + const pairsToDelete = getCustomerProductItemPairs({ + targetCustomerProduct, + }).filter((pair) => + (customize.remove_items ?? []).some((filter) => + planItemFilterMatchesCustomerPair({ + filter, + customerPrice: pair.customerPrice, + customerEntitlement: pair.customerEntitlement, + }), + ), + ); + + return deleteCustomerProductItems({ + pairsToDelete, + targetCustomerProduct, + }); +}; diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts new file mode 100644 index 000000000..d5799599e --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts @@ -0,0 +1,80 @@ +import type { + CustomizePlanV1, + FullCusProduct, + FullCustomerPrice, + FullProduct, + Price, + SharedContext, +} from "@autumn/shared"; +import { basePriceToProductItem } from "@shared/api/products/components/basePrice/basePriceToProductItem"; +import { customerProductToBasePrice } from "@shared/utils/cusProductUtils/convertCusProduct/customerProductToPrice"; +import { itemToPriceAndEnt } from "@shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt"; + +const removeCurrentBasePrice = ({ + targetCustomerProduct, +}: { + targetCustomerProduct: FullCusProduct; +}): FullCustomerPrice[] => { + const basePrice = customerProductToBasePrice({ + customerProduct: targetCustomerProduct, + errorOnNotFound: false, + }); + if (!basePrice) return []; + + const customerPrices = targetCustomerProduct.customer_prices.filter( + (customerPrice) => customerPrice.price.id === basePrice.id, + ); + const customerPriceIds = new Set( + customerPrices.map((customerPrice) => customerPrice.id), + ); + + targetCustomerProduct.customer_prices = + targetCustomerProduct.customer_prices.filter( + (customerPrice) => !customerPriceIds.has(customerPrice.id), + ); + + return customerPrices; +}; + +export const handleCustomizePrice = ({ + ctx, + customize, + targetCustomerProduct, + fullProduct, +}: { + ctx: SharedContext; + customize: CustomizePlanV1; + targetCustomerProduct: FullCusProduct; + fullProduct: FullProduct; +}): { + customerPrices: FullCustomerPrice[]; + prices: Price[]; +} => { + if (customize.price === undefined) { + return { customerPrices: [], prices: [] }; + } + + const customerPrices = removeCurrentBasePrice({ targetCustomerProduct }); + + if (customize.price === null) { + return { customerPrices, prices: [] }; + } + + const item = basePriceToProductItem({ + ctx, + basePrice: customize.price, + }); + const { newPrice, updatedPrice } = itemToPriceAndEnt({ + item, + orgId: fullProduct.org_id, + internalProductId: fullProduct.internal_id, + isCustom: true, + features: ctx.features, + }); + const price = newPrice ?? updatedPrice; + const prices = price ? [price] : []; + + fullProduct.prices.push(...prices); + + return { customerPrices, prices }; +}; diff --git a/server/src/internal/billing/v2/setup/patch/index.ts b/server/src/internal/billing/v2/setup/patch/index.ts new file mode 100644 index 000000000..c02713267 --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/index.ts @@ -0,0 +1,3 @@ +export * from "./handleCustomizeAddItems"; +export * from "./handleCustomizeDeleteItems"; +export * from "./setupPatchContext"; diff --git a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts new file mode 100644 index 000000000..2a882e9d2 --- /dev/null +++ b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts @@ -0,0 +1,160 @@ +import { + cusProductToProduct, + type FullCusProduct, + type FullProduct, + isCustomizePlanPatchStyle, + type PatchContext, + type SharedContext, + type UpdateSubscriptionV1Params, +} from "@autumn/shared"; +import { isFixedPrice } from "@shared/utils/productUtils/priceUtils/classifyPriceUtils"; +import { duplicateCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct"; +import { generateId } from "@/utils/genUtils"; +import { handleCustomizeAddItems } from "./handleCustomizeAddItems"; +import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems"; +import { handleCustomizePrice } from "./handleCustomizePrice"; + +const applyProductDefinitionToCustomerProduct = ({ + fullProduct, + customerProduct, +}: { + fullProduct: FullProduct; + customerProduct: FullCusProduct; +}) => { + const { + prices: _prices, + entitlements: _entitlements, + free_trial, + ...product + } = fullProduct; + + customerProduct.internal_product_id = fullProduct.internal_id; + customerProduct.product = product; + customerProduct.free_trial = free_trial ?? null; +}; + +const uniqueCustomerPrices = ( + customerPrices: FullCusProduct["customer_prices"], +) => + Array.from( + new Map( + customerPrices.map((customerPrice) => [customerPrice.id, customerPrice]), + ).values(), + ); + +const applyProductBasePriceToCustomerProduct = ({ + fullProduct, + customerProduct, +}: { + fullProduct: FullProduct; + customerProduct: FullCusProduct; +}) => { + const productBasePrice = fullProduct.prices.find(isFixedPrice); + if (!productBasePrice) return; + + const currentBasePrice = customerProduct.customer_prices.find((customerPrice) => + isFixedPrice(customerPrice.price), + ); + + if (!currentBasePrice) { + customerProduct.customer_prices.push({ + id: generateId("cus_price"), + internal_customer_id: customerProduct.internal_customer_id, + customer_product_id: customerProduct.id, + created_at: Date.now(), + price_id: productBasePrice.id, + price: productBasePrice, + }); + return; + } + + currentBasePrice.price_id = productBasePrice.id; + currentBasePrice.price = productBasePrice; +}; + +export const setupPatchContext = ({ + ctx, + params, + customerProduct, + fullProduct, +}: { + ctx: SharedContext; + params: UpdateSubscriptionV1Params; + customerProduct: FullCusProduct; + fullProduct: FullProduct; +}): PatchContext | undefined => { + if (!isCustomizePlanPatchStyle(params.customize)) return undefined; + + const mode = + params.version !== undefined && + params.version !== customerProduct.product.version + ? "new" + : "existing"; + + const finalCustomerProduct = + mode === "new" + ? duplicateCustomerProduct({ + customerProduct, + newInternalProductId: fullProduct.internal_id, + }) + : structuredClone(customerProduct); + + applyProductDefinitionToCustomerProduct({ + fullProduct, + customerProduct: finalCustomerProduct, + }); + + if (mode === "new" && params.customize?.price === undefined) { + applyProductBasePriceToCustomerProduct({ + fullProduct, + customerProduct: finalCustomerProduct, + }); + } + + const { + customerPrices: deleteCustomerPrices, + customerEntitlements: deleteCustomerEntitlements, + } = handleCustomizeDeleteItems({ + customize: params.customize, + targetCustomerProduct: finalCustomerProduct, + }); + + const patchFullProduct = cusProductToProduct({ + cusProduct: finalCustomerProduct, + }); + + const { + customerPrices: deletePriceCustomerPrices, + prices: customPricePrices, + } = handleCustomizePrice({ + ctx, + customize: params.customize, + targetCustomerProduct: finalCustomerProduct, + fullProduct: patchFullProduct, + }); + + const { prices: customItemPrices, entitlements: customEntitlements } = + handleCustomizeAddItems({ + ctx, + customize: params.customize, + fullProduct: patchFullProduct, + }); + + const patchContext: PatchContext = { + originalCustomerProduct: customerProduct, + mode, + finalCustomerProduct, + fullProduct: patchFullProduct, + insertCustomerPrices: [], + insertCustomerEntitlements: [], + deleteCustomerPrices: uniqueCustomerPrices([ + ...deleteCustomerPrices, + ...deletePriceCustomerPrices, + ]), + deleteCustomerEntitlements, + customPrices: [...customPricePrices, ...customItemPrices], + customEntitlements, + }; + + return patchContext; +}; diff --git a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts index 3a353fec5..2fe1537f0 100644 --- a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts +++ b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts @@ -3,6 +3,7 @@ import { customizePlanV1ToV0, type FullProduct, hasCustomItems, + type PatchContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils"; @@ -13,13 +14,15 @@ export const setupCustomFullProduct = async ({ // customItems, currentFullProduct, customizePlan, + patchContext, }: { ctx: AutumnContext; // customItems?: ProductItem[]; currentFullProduct: FullProduct; customizePlan?: CustomizePlanV1; + patchContext?: PatchContext; }) => { - if (!hasCustomItems(customizePlan)) { + if (patchContext || !hasCustomItems(customizePlan)) { return { fullProduct: currentFullProduct, customPrices: [], diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts index 2f594a078..f3dff4871 100644 --- a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -1,7 +1,9 @@ import type { AutumnBillingPlan, BillingContext } from "@autumn/shared"; import { + applyCustomerProductPatch, applyCustomerProductUpdate, getDeleteCustomerProducts, + getPatchCustomerProducts, getUpdateCustomerProducts, } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; @@ -12,12 +14,15 @@ export const autumnBillingPlanToFinalFullCustomer = ({ billingContext: BillingContext; autumnBillingPlan: AutumnBillingPlan; }) => { - const { - insertCustomerProducts, - updateCustomerEntitlements, - } = autumnBillingPlan; - const deleteCustomerProducts = getDeleteCustomerProducts({ autumnBillingPlan }); - const updateCustomerProducts = getUpdateCustomerProducts({ autumnBillingPlan }); + const { insertCustomerProducts, updateCustomerEntitlements } = + autumnBillingPlan; + const deleteCustomerProducts = getDeleteCustomerProducts({ + autumnBillingPlan, + }); + const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan }); + const updateCustomerProducts = getUpdateCustomerProducts({ + autumnBillingPlan, + }); const finalFullCustomer = structuredClone(billingContext.fullCustomer); @@ -27,21 +32,32 @@ export const autumnBillingPlanToFinalFullCustomer = ({ ...insertCustomerProducts, ]; - let customerProducts = combinedCustomerProducts.map((customerProduct) => - updateCustomerProducts.find( + let customerProducts = combinedCustomerProducts.map((customerProduct) => { + const updateCustomerProduct = updateCustomerProducts.find( (updateCustomerProduct) => updateCustomerProduct.customerProduct.id === customerProduct.id, - ) - ? applyCustomerProductUpdate({ - customerProduct, - updates: - updateCustomerProducts.find( - (updateCustomerProduct) => - updateCustomerProduct.customerProduct.id === customerProduct.id, - )!.updates, - }) - : customerProduct, - ); + ); + const patchCustomerProduct = patchCustomerProducts.find( + (patchCustomerProduct) => + patchCustomerProduct.customerProduct.id === customerProduct.id, + ); + + let result = customerProduct; + if (updateCustomerProduct) { + result = applyCustomerProductUpdate({ + customerProduct: result, + updates: updateCustomerProduct.updates, + }); + } + if (patchCustomerProduct) { + result = applyCustomerProductPatch({ + customerProduct: result, + patch: patchCustomerProduct, + }); + } + + return result; + }); // 3. Remove deleted customer product if applicable if (deleteCustomerProducts.length > 0) { diff --git a/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts index 5db6e131b..e3fef5c25 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts @@ -1,5 +1,6 @@ import type { AutumnBillingPlan, FullCusProduct } from "@autumn/shared"; import { CusProductStatus } from "@autumn/shared"; +import { applyCustomerProductItemsPatch } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct"; export const getUpdateCustomerProducts = ({ autumnBillingPlan, @@ -23,6 +24,12 @@ export const getDeleteCustomerProducts = ({ ...(autumnBillingPlan.deleteCustomerProducts ?? []), ]; +export const getPatchCustomerProducts = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}) => autumnBillingPlan.patchCustomerProducts ?? []; + export const applyCustomerProductUpdate = ({ customerProduct, updates, @@ -37,6 +44,21 @@ export const applyCustomerProductUpdate = ({ canceled: updates.canceled ?? customerProduct.canceled, }); +export const applyCustomerProductPatch = ({ + customerProduct, + patch, +}: { + customerProduct: FullCusProduct; + patch: NonNullable[number]; +}): FullCusProduct => + applyCustomerProductItemsPatch({ + customerProduct, + insertCustomerPrices: patch.insertCustomerPrices, + insertCustomerEntitlements: patch.insertCustomerEntitlements, + deleteCustomerPrices: patch.deleteCustomerPrices, + deleteCustomerEntitlements: patch.deleteCustomerEntitlements, + }); + type CustomerProductUpdate = NonNullable< AutumnBillingPlan["updateCustomerProducts"] >[number]; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts index b201403c4..4ee718735 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts @@ -12,7 +12,12 @@ import { } from "@autumn/shared"; import type { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct"; +import { + applyCustomerProductPatch, + applyCustomerProductUpdate, + getPatchCustomerProducts, + getUpdateCustomerProducts, +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { billingPlanToNextCycleLineItems } from "./billingPlanToNextCycleLineItems"; import { computeScheduledAnchorResetPreview } from "./computeScheduledAnchorResetPreview"; @@ -30,6 +35,39 @@ export type NextCyclePreviewResult = { debug: NextCyclePreviewDebug; }; +const applyPatchCustomerProducts = ({ + allCustomerProducts, + billingPlan, +}: { + allCustomerProducts: FullCusProduct[]; + billingPlan: BillingPlan; +}): FullCusProduct[] => { + const patchCustomerProducts = getPatchCustomerProducts({ + autumnBillingPlan: billingPlan.autumn, + }); + if (patchCustomerProducts.length === 0) return allCustomerProducts; + + const patchedCustomerProducts = patchCustomerProducts.map((patch) => + applyCustomerProductPatch({ + customerProduct: + allCustomerProducts.find( + (customerProduct) => customerProduct.id === patch.customerProduct.id, + ) ?? patch.customerProduct, + patch, + }), + ); + const patchedCustomerProductIds = new Set( + patchedCustomerProducts.map((customerProduct) => customerProduct.id), + ); + + return [ + ...allCustomerProducts.filter( + (customerProduct) => !patchedCustomerProductIds.has(customerProduct.id), + ), + ...patchedCustomerProducts, + ]; +}; + export const billingPlanToNextCyclePreview = ({ ctx, billingContext, @@ -41,16 +79,18 @@ export const billingPlanToNextCyclePreview = ({ }): NextCyclePreviewResult => { const { billingCycleAnchorMs } = billingContext; - const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({ - autumnBillingPlan: billingPlan.autumn, - }); const { insertCustomerProducts } = billingPlan.autumn; + const updateCustomerProducts = getUpdateCustomerProducts({ + autumnBillingPlan: billingPlan.autumn, + }).map(({ customerProduct, updates }) => + applyCustomerProductUpdate({ customerProduct, updates }), + ); // Get all customer products - const allCustomerProducts = [ - ...insertCustomerProducts, - ...(updatedCustomerProduct ? [updatedCustomerProduct] : []), - ]; + const allCustomerProducts = applyPatchCustomerProducts({ + allCustomerProducts: [...insertCustomerProducts, ...updateCustomerProducts], + billingPlan, + }); const customerProducts = allCustomerProducts.filter( (customerProduct) => diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/duplicateCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/duplicateCustomerProduct.ts new file mode 100644 index 000000000..a322720f6 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/duplicateCustomerProduct.ts @@ -0,0 +1,60 @@ +import type { FullCusProduct } from "@autumn/shared"; +import { generateId } from "@/utils/genUtils"; + +export const duplicateCustomerProduct = ({ + customerProduct, + newInternalProductId, +}: { + customerProduct: FullCusProduct; + newInternalProductId: string; +}): FullCusProduct => { + const customerProductId = generateId("cus_prod"); + const now = Date.now(); + + const customerEntitlements = customerProduct.customer_entitlements.map( + (customerEntitlement) => { + const customerEntitlementId = generateId("cus_ent"); + + return { + ...customerEntitlement, + id: customerEntitlementId, + customer_product_id: customerProductId, + created_at: now, + replaceables: customerEntitlement.replaceables.map((replaceable) => ({ + ...replaceable, + id: generateId("rep"), + cus_ent_id: customerEntitlementId, + created_at: now, + })), + rollovers: customerEntitlement.rollovers.map((rollover) => ({ + ...rollover, + id: generateId("roll"), + cus_ent_id: customerEntitlementId, + })), + }; + }, + ); + + const customerPrices = customerProduct.customer_prices.map( + (customerPrice) => ({ + ...customerPrice, + id: generateId("cus_price"), + customer_product_id: customerProductId, + created_at: now, + }), + ); + + return { + ...customerProduct, + id: customerProductId, + internal_product_id: newInternalProductId, + product: { + ...customerProduct.product, + internal_id: newInternalProductId, + }, + created_at: now, + updated_at: now, + customer_entitlements: customerEntitlements, + customer_prices: customerPrices, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/index.ts new file mode 100644 index 000000000..671bfeef6 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct/index.ts @@ -0,0 +1 @@ +export * from "./duplicateCustomerProduct"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index 90250dfb6..d10d0d1bd 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -77,7 +77,8 @@ export const initCustomerProduct = ({ internal_product_id: fullProduct.internal_id, product_id: fullProduct.id, - created_at: Date.now(), + created_at: now, + updated_at: now, status, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/applyCustomerProductItemsPatch.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/applyCustomerProductItemsPatch.ts new file mode 100644 index 000000000..ca2c4a25c --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/applyCustomerProductItemsPatch.ts @@ -0,0 +1,45 @@ +import type { + FullCusProduct, + FullCustomerEntitlement, + FullCustomerPrice, +} from "@autumn/shared"; + +export const applyCustomerProductItemsPatch = ({ + customerProduct, + insertCustomerPrices, + insertCustomerEntitlements, + deleteCustomerPrices, + deleteCustomerEntitlements, +}: { + customerProduct: FullCusProduct; + insertCustomerPrices: FullCustomerPrice[]; + insertCustomerEntitlements: FullCustomerEntitlement[]; + deleteCustomerPrices: FullCustomerPrice[]; + deleteCustomerEntitlements: FullCustomerEntitlement[]; +}): FullCusProduct => { + const deleteCustomerPriceIds = new Set( + deleteCustomerPrices.map((customerPrice) => customerPrice.id), + ); + const deleteCustomerEntitlementIds = new Set( + deleteCustomerEntitlements.map( + (customerEntitlement) => customerEntitlement.id, + ), + ); + + return { + ...customerProduct, + customer_prices: [ + ...customerProduct.customer_prices.filter( + (customerPrice) => !deleteCustomerPriceIds.has(customerPrice.id), + ), + ...insertCustomerPrices, + ], + customer_entitlements: [ + ...customerProduct.customer_entitlements.filter( + (customerEntitlement) => + !deleteCustomerEntitlementIds.has(customerEntitlement.id), + ), + ...insertCustomerEntitlements, + ], + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts new file mode 100644 index 000000000..3fb2a3c2e --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts @@ -0,0 +1,29 @@ +import type { FullCusProduct, PatchContext } from "@autumn/shared"; + +export const getPatchCarryCustomerProduct = ({ + patchContext, +}: { + patchContext: PatchContext; +}): FullCusProduct => { + const deletedEntitlementIds = new Set( + patchContext.deleteCustomerEntitlements.map( + (customerEntitlement) => customerEntitlement.entitlement.id, + ), + ); + const deletedCustomerPriceIds = new Set( + patchContext.deleteCustomerPrices.map((customerPrice) => customerPrice.id), + ); + + return { + ...patchContext.originalCustomerProduct, + customer_prices: + patchContext.originalCustomerProduct.customer_prices.filter( + (customerPrice) => + deletedCustomerPriceIds.has(customerPrice.id) || + (customerPrice.price.entitlement_id + ? deletedEntitlementIds.has(customerPrice.price.entitlement_id) + : false), + ), + customer_entitlements: patchContext.deleteCustomerEntitlements, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts new file mode 100644 index 000000000..f7f907c78 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts @@ -0,0 +1,4 @@ +export * from "./applyCustomerProductItemsPatch"; +export * from "./getPatchCarryCustomerProduct"; +export * from "./initPatchCustomerProduct"; +export * from "./initPatchedCustomerEntitlementsAndPrices"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts new file mode 100644 index 000000000..38db5a14b --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts @@ -0,0 +1,56 @@ +import { + cusProductToProduct, + type PatchContext, + type UpdateSubscriptionBillingContext, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { applyCustomerProductItemsPatch } from "./applyCustomerProductItemsPatch"; +import { initPatchedCustomerEntitlementsAndPrices } from "./initPatchedCustomerEntitlementsAndPrices"; + +/** + * Materializes the added side of a patch-style custom plan update. + * + * `setupPatchContext` has already removed the requested customer prices and + * entitlements from `finalCustomerProduct` and recorded those rows on the patch + * context. This function initializes customer rows for `customPrices` and + * `customEntitlements`, carries usage and rollovers only from the deleted patch + * items, inserts the new rows into `finalCustomerProduct`, and rebuilds the + * derived `fullProduct` snapshot from that final customer-product state. + */ +export const initPatchCustomerProduct = ({ + ctx, + billingContext, + patchContext, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionBillingContext; + patchContext: PatchContext; +}) => { + const { customerPrices, customerEntitlements } = + initPatchedCustomerEntitlementsAndPrices({ + ctx, + billingContext, + patchContext, + }); + + const patchedCustomerProduct = applyCustomerProductItemsPatch({ + customerProduct: patchContext.finalCustomerProduct, + insertCustomerPrices: customerPrices, + insertCustomerEntitlements: customerEntitlements, + deleteCustomerPrices: [], + deleteCustomerEntitlements: [], + }); + + patchContext.finalCustomerProduct.customer_prices = + patchedCustomerProduct.customer_prices; + patchContext.finalCustomerProduct.customer_entitlements = + patchedCustomerProduct.customer_entitlements; + patchContext.finalCustomerProduct.options = billingContext.featureQuantities; + patchContext.insertCustomerPrices = customerPrices; + patchContext.insertCustomerEntitlements = customerEntitlements; + patchContext.fullProduct = cusProductToProduct({ + cusProduct: patchContext.finalCustomerProduct, + }); + + return patchContext.finalCustomerProduct; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts new file mode 100644 index 000000000..1bc08ce19 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts @@ -0,0 +1,110 @@ +import type { + FullCustomerEntitlement, + FullCustomerPrice, + PatchContext, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; +import { enrichEntitlementsWithFeatures } from "@shared/utils/productUtils/entUtils/enrichEntitlement"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { applyExistingStatesToCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/applyExisting/applyExistingStatesToCustomerProduct"; +import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement"; +import { initCustomerPrice } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice"; +import { getPatchCarryCustomerProduct } from "./getPatchCarryCustomerProduct"; + +type PatchInitBillingContext = Pick< + UpdateSubscriptionBillingContext, + | "fullCustomer" + | "featureQuantities" + | "resetCycleAnchorMs" + | "currentEpochMs" + | "trialContext" + | "skipExistingUsageCarry" +>; + +export const initPatchedCustomerEntitlementsAndPrices = ({ + ctx, + billingContext, + patchContext, +}: { + ctx: AutumnContext; + billingContext: PatchInitBillingContext; + patchContext: PatchContext; +}): { + customerPrices: FullCustomerPrice[]; + customerEntitlements: FullCustomerEntitlement[]; +} => { + const { + fullCustomer, + featureQuantities, + resetCycleAnchorMs, + currentEpochMs, + trialContext, + skipExistingUsageCarry, + } = billingContext; + const { + customPrices, + customEntitlements, + finalCustomerProduct, + fullProduct, + } = patchContext; + + const customerPrices = customPrices.map((price) => ({ + ...initCustomerPrice({ + fullCus: fullCustomer, + price, + cusProductId: finalCustomerProduct.id, + }), + price, + })); + + const entitlementsWithFeatures = enrichEntitlementsWithFeatures({ + entitlements: customEntitlements, + features: ctx.features, + }); + + const customerEntitlements = entitlementsWithFeatures.map((entitlement) => ({ + ...initCustomerEntitlement({ + initContext: { + fullCustomer, + fullProduct, + featureQuantities, + resetCycleAnchor: resetCycleAnchorMs, + freeTrial: trialContext?.freeTrial ?? null, + trialEndsAt: trialContext?.trialEndsAt ?? undefined, + now: currentEpochMs, + }, + entitlement, + cusProductId: finalCustomerProduct.id, + }), + entitlement, + replaceables: [], + rollovers: [], + })); + + const customerProductWithNewItemsOnly = { + ...finalCustomerProduct, + customer_prices: customerPrices, + customer_entitlements: customerEntitlements, + }; + const carryCustomerProduct = getPatchCarryCustomerProduct({ patchContext }); + + applyExistingStatesToCustomerProduct({ + ctx, + fullCustomer, + customerProduct: customerProductWithNewItemsOnly, + existingUsagesConfig: skipExistingUsageCarry + ? undefined + : { + fromCustomerProduct: carryCustomerProduct, + carryAllConsumableFeatures: true, + }, + existingRolloversConfig: { + fromCustomerProduct: carryCustomerProduct, + }, + }); + + return { + customerPrices: customerProductWithNewItemsOnly.customer_prices, + customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index 5e576d13f..12c94a217 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -24,6 +24,7 @@ export const initScheduledCustomerProduct = ({ startsAt, endsAt, currentEpochMs, + externalId, subscriptionId, subscriptionScheduleId, }: { @@ -34,6 +35,8 @@ export const initScheduledCustomerProduct = ({ startsAt: number; endsAt: number | null | undefined; currentEpochMs: number; + /** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */ + externalId?: string; /** When syncing from an existing Stripe sub/schedule, link the resulting * scheduled cusProduct back to it so the customer-products view shows the * Stripe linkage and downstream actions (cancel, restore) can find it. */ @@ -55,6 +58,7 @@ export const initScheduledCustomerProduct = ({ startsAt, endedAt: endsAt ?? undefined, status: CusProductStatus.Scheduled, + externalId, subscriptionId, subscriptionScheduleId, }, diff --git a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts index a61af94a3..e673d4868 100644 --- a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts @@ -46,6 +46,24 @@ export const logAutumnBillingPlan = ({ ? formatCustomerProduct(plan.deleteCustomerProduct) : "none", + patchCustomerProducts: + plan.patchCustomerProducts + ?.map( + (patch) => + `${formatCustomerProduct(patch.customerProduct)}: +${patch.insertCustomerEntitlements.length} ent, +${patch.insertCustomerPrices.length} price | -${patch.deleteCustomerEntitlements.length} ent, -${patch.deleteCustomerPrices.length} price`, + ) + .join(", ") || "none", + + customPrices: + (plan.customPrices?.length ?? 0) > 0 + ? `${plan.customPrices?.length} custom price(s)` + : "none", + + customEntitlements: + (plan.customEntitlements?.length ?? 0) > 0 + ? `${plan.customEntitlements?.length} custom ent(s)` + : "none", + trialTransition: `${isTrialing ? "trialing" : "not trialing"} -> ${willBeTrialing ? "will trial" : "no trial"}`, updateCustomerEntitlements: diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 56b41eb22..72ed26115 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -122,13 +122,17 @@ const initCusProduct = ({ trialEnds = freeTrialToStripeTimestamp({ freeTrial })! * 1000; } + const now = Date.now(); + const created = createdAt || now; + return { id: cusProdId, internal_customer_id: customer.internal_id, customer_id: customer.id, internal_product_id: product.internal_id, product_id: product.id, - created_at: createdAt || Date.now(), + created_at: created, + updated_at: created, canceled: notNullish(canceledAt), ended_at: endedAt, status: subscriptionStatus @@ -144,7 +148,7 @@ const initCusProduct = ({ // last_invoice_id: lastInvoiceId, }, - starts_at: startsAt || Date.now(), + starts_at: startsAt || now, trial_ends_at: trialEnds, options: optionsList || [], free_trial_id: freeTrial?.id || null, diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index 60a39c63c..6b55201f2 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -447,7 +447,7 @@ export class CusProductService { const { db } = ctx; return await db .update(customerProducts) - .set(updates) + .set({ ...updates, updated_at: Date.now() }) .where(eq(customerProducts.id, cusProductId)); } @@ -466,7 +466,7 @@ export class CusProductService { }) { const updated = await db .update(customerProducts) - .set(updates) + .set({ ...updates, updated_at: Date.now() }) .where( and( arrayContains(customerProducts.subscription_ids, [stripeSubId]), @@ -501,11 +501,11 @@ export class CusProductService { }: { db: DrizzleCli; stripeScheduledId: string; - updates: Partial; + updates: Partial; }) { const updated = await db .update(customerProducts) - .set(updates as any) + .set({ ...updates, updated_at: Date.now() }) .where( and( arrayContains(customerProducts.scheduled_ids, [stripeScheduledId]), diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 2e4fabe27..7ccbbd9c3 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -2,14 +2,14 @@ import type { TestGroup } from "./types"; export const temp: TestGroup = { name: "temp", - description: - "sub.created auto-sync + sync param detection + restore (Autumn → Stripe)", + description: "patch-style custom plan update coverage", tier: "domain", paths: [ - // "integration/billing/stripe-webhooks/subscription-created", - "integration/billing/sync/to-sync-params", - // "integration/billing/restore", - "integration/balances/track/basic/track-credit-system-all-balances", - "integration/balances/check/check-send-event-credit-system", + "integration/billing/update-subscription/custom-plan-patch/patch-update-items.test.ts", + "integration/billing/update-subscription/custom-plan-patch/patch-update-price.test.ts", + "integration/billing/update-subscription/custom-plan-patch/patch-update-paid-features.test.ts", + "integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-usage.test.ts", + "integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-rollover.test.ts", + "integration/billing/update-subscription/custom-plan-patch/patch-update-with-others.test.ts", ], }; diff --git a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts index 302d6bb83..195788b75 100644 --- a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts +++ b/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts @@ -1622,26 +1622,6 @@ test.concurrent(`${chalk.yellowBright("create-schedule: rejects invalid timing a }, }); - await expectAutumnError({ - errMessage: "subscription_id is not supported", - func: async () => { - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: pro.id, - subscription_id: "sub_123", - }, - ], - }, - ], - }); - }, - }); - await expectAutumnError({ errMessage: 'Unrecognized key: "free_trial"', func: async () => { diff --git a/server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts new file mode 100644 index 000000000..bd12d2c28 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts @@ -0,0 +1,169 @@ +/** + * TDD test for create_schedule per-plan subscription_id support. + * + * Contract under test: + * New types/fields: + * - CreateScheduleParamsV0.phases[].plans[].subscription_id?: string + * New endpoints: + * - POST /billing.create_schedule accepts subscription_id for immediate and future phase plans + * New behaviors: + * - The provided value becomes the Autumn subscription API id in customer.subscriptions[].id + * - Duplicate subscription_id values in one request, including across phases, reject with DuplicateSubscriptionId + * Side effects: + * - Persist subscription_id in customer_products.external_id + * - Do not persist the provided subscription_id in customer_products.subscription_ids + * + * Pre-impl red: create_schedule schema rejects subscription_id and future phases cannot carry it into customer_products.external_id. + * Post-impl green: immediate and scheduled customer products expose the requested API ids and duplicate ids are rejected. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type CreateScheduleParamsV0Input, + CusProductStatus, + ErrCode, + customerProducts, + ms, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { inArray } from "drizzle-orm"; + +test.concurrent( + `${chalk.yellowBright("create-schedule subscription_id: persists immediate and future plan ids")}`, + async () => { + const customerId = "create-schedule-sub-id"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { autumnV1, autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id, subscription_id: "main-sub" }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id, subscription_id: "premium-sub" }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + expect(response.phases).toHaveLength(2); + + const customerProductIds = response.phases.flatMap( + (phase) => phase.customer_product_ids, + ); + const rows = await ctx.db + .select({ + id: customerProducts.id, + productId: customerProducts.product_id, + status: customerProducts.status, + externalId: customerProducts.external_id, + subscriptionIds: customerProducts.subscription_ids, + }) + .from(customerProducts) + .where(inArray(customerProducts.id, customerProductIds)); + + const mainRow = rows.find((row) => row.productId === pro.id); + const premiumRow = rows.find((row) => row.productId === premium.id); + + expect(mainRow).toMatchObject({ + status: CusProductStatus.Active, + externalId: "main-sub", + }); + expect(premiumRow).toMatchObject({ + status: CusProductStatus.Scheduled, + externalId: "premium-sub", + }); + expect(mainRow?.subscriptionIds ?? []).not.toContain("main-sub"); + expect(premiumRow?.subscriptionIds ?? []).not.toContain("premium-sub"); + + const customer = await autumnV2_1.customers.get(customerId); + const mainSubscription = customer.subscriptions.find( + (subscription) => subscription.id === "main-sub", + ); + const premiumSubscription = customer.subscriptions.find( + (subscription) => subscription.id === "premium-sub", + ); + + expect(mainSubscription).toMatchObject({ + plan_id: pro.id, + status: "active", + }); + expect(premiumSubscription).toMatchObject({ + plan_id: premium.id, + status: "scheduled", + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule subscription_id: duplicate ids across phases reject")}`, + async () => { + const customerId = "create-schedule-sub-id-dup"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + await expectAutumnError({ + errCode: ErrCode.DuplicateSubscriptionId, + func: async () => { + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id, subscription_id: "same-sub" }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id, subscription_id: "same-sub" }], + }, + ], + }); + }, + }); + }, +); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-rollover.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-rollover.test.ts new file mode 100644 index 000000000..efc9d14af --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-rollover.test.ts @@ -0,0 +1,235 @@ +/** + * TDD coverage for patch-style item replacement rollover carry. + * + * Contract under test: + * New behaviors: + * - remove_items + add_items carries active rollovers from the deleted + * customer entitlement into the newly added matching entitlement. + * - A 50% max_percentage rollover on prepaid + consumable messages survives + * a prepaid item patch without replacing the customer product. + * Side effects: + * - Existing-mode patch updates do not expire or replace the customer product. + * - Stripe subscription state stays consistent with the patched customer product. + * + * Pre-impl red: patch init may initialize the added customer entitlement without + * the rollovers attached to the deleted entitlement. + * Post-impl green: patch init scopes rollover carry to deleted patch items and + * applies it to the corresponding inserted customer entitlement. + */ + +import { test } from "bun:test"; +import { + type ApiCustomerV5, + BillingInterval, + BillingMethod, + ResetInterval, + RolloverExpiryDurationType, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem"; + +const monthlyRolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const apiMonthlyRolloverConfig = { + max: 500, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 1, +}; + +const maxPercentageRolloverConfig = { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const apiMaxPercentageRolloverConfig = { + max_percentage: 50, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 1, +}; + +test.concurrent(`${chalk.yellowBright("patch update items carry rollover: metered rollover carries to added item")}`, async () => { + const customerId = "patch-items-carry-rollover-metered"; + const base = products.base({ + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: 400, + rolloverConfig: monthlyRolloverConfig, + }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [ + s.billing.attach({ productId: base.id }), + s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }), + s.resetFeature({ featureId: TestFeature.Messages }), + ], + }); + + const customerAfterInvoice = + await autumnV2_2.customers.get(customerId); + + expectBalanceCorrect({ + customer: customerAfterInvoice, + featureId: TestFeature.Messages, + remaining: 550, + usage: 0, + rollovers: [{ balance: 150 }], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: base.id, + customize: { + remove_items: [ + { + feature_id: TestFeature.Messages, + interval: BillingInterval.Month, + }, + ], + add_items: [ + { + feature_id: TestFeature.Messages, + included: 500, + reset: { interval: ResetInterval.Month }, + rollover: apiMonthlyRolloverConfig, + }, + ], + }, + }; + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customerAfterPatch = + await autumnV2_2.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPatch, + active: [base.id], + }); + expectBalanceCorrect({ + customer: customerAfterPatch, + featureId: TestFeature.Messages, + remaining: 650, + usage: 0, + rollovers: [{ balance: 150 }], + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: base.id, + }); +}); + +test.concurrent(`${chalk.yellowBright("patch update items carry rollover: prepaid and consumable 50 percent rollover survives")}`, async () => { + const customerId = "patch-items-carry-rollover-prepaid-consumable"; + const quantity = 1500; + const expectedRollover = quantity / 2; + const expectedRemaining = quantity + expectedRollover; + + const prepaidMessagesItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 1, + price: 0.25, + rolloverConfig: maxPercentageRolloverConfig, + }); + + const pro = products.pro({ + items: [prepaidMessagesItem, items.consumableMessages({ price: 0.1 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + s.advanceToNextInvoice(), + ], + }); + + const customerAfterInvoice = + await autumnV2_2.customers.get(customerId); + + expectBalanceCorrect({ + customer: customerAfterInvoice, + featureId: TestFeature.Messages, + remaining: expectedRemaining, + usage: 0, + rollovers: [{ balance: expectedRollover }], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [ + { + feature_id: TestFeature.Messages, + billing_method: BillingMethod.Prepaid, + }, + ], + add_items: [ + { + feature_id: TestFeature.Messages, + included: 100, + price: { + amount: 0.5, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: 1, + }, + rollover: apiMaxPercentageRolloverConfig, + }, + ], + }, + }; + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customerAfterPatch = + await autumnV2_2.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterPatch, + active: [pro.id], + }); + expectBalanceCorrect({ + customer: customerAfterPatch, + featureId: TestFeature.Messages, + remaining: expectedRemaining, + usage: 0, + rollovers: [{ balance: expectedRollover }], + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-usage.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-usage.test.ts new file mode 100644 index 000000000..d72542bf9 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items-carry-usage.test.ts @@ -0,0 +1,178 @@ +/** + * TDD coverage for patch-style item replacement usage carry. + * + * Contract under test: + * New behaviors: + * - remove_items + add_items carries usage only from the customer entitlement + * being replaced, not from every entitlement on the same feature. + * - Replacing one metered feature item does not change usage on unrelated + * metered feature items. + * Side effects: + * - Existing-mode patch updates do not expire or replace the customer product. + * - Stripe subscription state stays consistent with the patched customer product. + * + * Pre-impl red: patch init carries all consumable usage from the original customer + * product into the new patch item, including same-feature entitlements that were + * not removed. + * Post-impl green: patch init builds existing usage/rollover state from only the + * deleted customer entitlements that correspond to the inserted patch items. + */ + +import { test } from "bun:test"; +import type { + ApiCustomerV5, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { BillingInterval, ResetInterval } from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +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.concurrent(`${chalk.yellowBright("patch update items carry usage: same feature monthly replacement ignores lifetime usage")}`, async () => { + const customerId = "patch-items-carry-usage-same-feature"; + const pro = products.pro({ + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.lifetimeMessages({ includedUsage: 500 }), + ], + }); + + const { autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + interval: ResetInterval.Month, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 200, + interval: ResetInterval.OneOff, + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [ + { + feature_id: TestFeature.Messages, + interval: BillingInterval.Month, + }, + ], + add_items: [itemsV2.monthlyMessages({ included: 200 })], + }, + }; + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 350, + usage: 350, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 200, + remaining: 150, + usage: 50, + }, + [ResetInterval.OneOff]: { + included_grant: 500, + remaining: 200, + usage: 300, + }, + }, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update items carry usage: replacing messages keeps words usage unchanged")}`, async () => { + const customerId = "patch-items-carry-usage-other-feature"; + const pro = products.pro({ + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyWords({ includedUsage: 250 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 60, + }, + { timeout: 2000 }, + ); + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Words, + value: 80, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 200 })], + }, + }; + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 140, + usage: 60, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Words, + remaining: 170, + usage: 80, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items.test.ts new file mode 100644 index 000000000..6c573fa4b --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-items.test.ts @@ -0,0 +1,256 @@ +/** + * TDD coverage for patch-style custom plan item updates. + * + * Red-failure mode (current behavior): + * - add_items/remove_items can build an incomplete patch plan or lose usage while replacing feature items. + * + * Green-success criteria (after fix): + * - Patch updates add/remove only the requested feature items, and existing usage carries into matching replacements. + */ + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV5, + ApiEntityV2, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +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.concurrent(`${chalk.yellowBright("patch update items: add boolean and metered entitlements")}`, async () => { + const customerId = "patch-update-items-add"; + const pro = products.pro({ items: [] }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + add_items: [itemsV2.dashboard(), itemsV2.monthlyWords({ included: 150 })], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Words, + remaining: 150, + usage: 0, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update items: remove boolean and metered entitlements")}`, async () => { + const customerId = "patch-update-items-remove"; + const pro = products.pro({ + items: [items.dashboard(), items.monthlyWords({ includedUsage: 120 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [ + { feature_id: TestFeature.Dashboard }, + { feature_id: TestFeature.Words }, + ], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + present: false, + }); + expect(customer.balances[TestFeature.Words]).toBeUndefined(); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update items: replace included messages and preserve usage")}`, async () => { + const customerId = "patch-update-items-replace-messages"; + const messagesUsage = 40; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 250 })], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 210, + usage: messagesUsage, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update items: entity update only affects target entity")}`, async () => { + const customerId = "patch-update-items-entity"; + const pro = products.pro({ items: [] }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + entity_id: entities[1].id, + plan_id: pro.id, + customize: { + add_items: [itemsV2.dashboard(), itemsV2.monthlyWords({ included: 75 })], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + + const entity1 = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + + expectFlagCorrect({ + customer: entity1, + featureId: TestFeature.Dashboard, + present: false, + }); + expect(entity1.balances[TestFeature.Words]).toBeUndefined(); + expectFlagCorrect({ + customer: entity2, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer: entity2, + featureId: TestFeature.Words, + remaining: 75, + usage: 0, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: pro.id, + entityId: entities[1].id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-paid-features.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-paid-features.test.ts new file mode 100644 index 000000000..18e6c4e45 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-paid-features.test.ts @@ -0,0 +1,393 @@ +/** + * TDD coverage for patch-style paid feature item updates. + * + * Contract under test: + * New behaviors: + * - remove_items + add_items can replace a free metered item with prepaid + * messages, preserve existing usage, and bill the prepaid quantity. + * - remove_items + add_items can replace a free metered item with consumable + * messages, preserve existing usage, and avoid immediate overage billing. + * - Entity-scoped paid feature patches only affect the targeted entity. + * Side effects: + * - Preview total matches the invoice total when a paid patch bills now. + * - Existing-mode patch updates do not expire or replace the customer product. + * - Stripe subscription state stays consistent with the patched customer product. + * + * Pre-impl red: patch setup/compute may miss paid feature prices when add_items + * contains prepaid or consumable items, or may lose carried usage. + * Post-impl green: paid feature patch rows are initialized, invoiced, and applied + * through patchCustomerProducts while preserving usage. + */ + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV3, + ApiCustomerV5, + ApiEntityV2, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { BillingInterval, BillingMethod } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +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 { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan"; +import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext"; + +test.concurrent(`${chalk.yellowBright("patch paid features: free messages to prepaid with usage carry")}`, async () => { + const customerId = "patch-paid-features-to-prepaid"; + const messagesUsage = 60; + const quantity = 300; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + feature_quantities: [{ feature_id: TestFeature.Messages, quantity }], + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + itemsV2.dashboard(), + itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), + ], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(30); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: quantity - messagesUsage, + usage: messagesUsage, + planId: pro.id, + breakdown: { + [BillingMethod.Prepaid]: { + prepaid_grant: quantity, + remaining: quantity - messagesUsage, + usage: messagesUsage, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + latestTotal: preview.total, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch paid features: free messages to consumable with usage carry")}`, async () => { + const customerId = "patch-paid-features-to-consumable"; + const messagesUsage = 60; + const included = 50; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + itemsV2.dashboard(), + { + ...itemsV2.consumableMessages({ amount: 0.1 }), + included, + }, + ], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 0, + usage: messagesUsage, + planId: pro.id, + breakdown: { + [BillingMethod.UsageBased]: { + included_grant: included, + remaining: 0, + usage: messagesUsage, + }, + }, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch paid features: entity prepaid patch only affects target entity")}`, async () => { + const customerId = "patch-paid-features-entity"; + const messagesUsage = 40; + const quantity = 300; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + entity_id: entities[1].id, + plan_id: pro.id, + feature_quantities: [{ feature_id: TestFeature.Messages, quantity }], + customize: { + remove_items: [ + { + feature_id: TestFeature.Messages, + interval: BillingInterval.Month, + }, + ], + add_items: [ + itemsV2.dashboard(), + itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), + ], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(30); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const entity1 = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + + expectFlagCorrect({ + customer: entity1, + featureId: TestFeature.Dashboard, + present: false, + }); + expectBalanceCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + remaining: 100, + usage: 0, + planId: pro.id, + }); + expectFlagCorrect({ + customer: entity2, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + remaining: quantity - messagesUsage, + usage: messagesUsage, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 3, + latestTotal: preview.total, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: pro.id, + entityId: entities[1].id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch paid features: consumable to prepaid optionally charges existing overage")}`, async () => { + const customerId = "patch-paid-features-charge-existing-overage"; + const messagesUsage = 80; + const quantity = 300; + const pro = products.pro({ + items: [items.consumableMessages({ includedUsage: 50, price: 0.1 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + feature_quantities: [{ feature_id: TestFeature.Messages, quantity }], + customize: { + remove_items: [ + { + feature_id: TestFeature.Messages, + billing_method: BillingMethod.UsageBased, + }, + ], + add_items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 })], + }, + }; + const internalUpdateParams = updateParams as Parameters< + typeof setupUpdateSubscriptionBillingContext + >[0]["params"]; + + const defaultBillingContext = await setupUpdateSubscriptionBillingContext({ + ctx, + params: internalUpdateParams, + }); + const defaultPlan = await computeUpdateSubscriptionPlan({ + ctx, + billingContext: defaultBillingContext, + params: internalUpdateParams, + }); + const defaultTotal = defaultPlan.lineItems?.reduce( + (total, lineItem) => total + lineItem.amount, + 0, + ); + expect(defaultTotal).toBe(30); + expect( + defaultPlan.patchCustomerProducts?.[0]?.insertCustomerEntitlements[0] + ?.balance, + ).toBe(quantity - messagesUsage); + + const chargeExistingBillingContext = + await setupUpdateSubscriptionBillingContext({ + ctx, + params: internalUpdateParams, + contextOverride: { + chargeExistingOverages: true, + skipExistingUsageCarry: true, + }, + }); + const chargeExistingPlan = await computeUpdateSubscriptionPlan({ + ctx, + billingContext: chargeExistingBillingContext, + params: internalUpdateParams, + }); + const chargeExistingTotal = chargeExistingPlan.lineItems?.reduce( + (total, lineItem) => total + lineItem.amount, + 0, + ); + expect(chargeExistingTotal).toBe(33); + expect( + chargeExistingPlan.patchCustomerProducts?.[0]?.insertCustomerEntitlements[0] + ?.balance, + ).toBe(quantity); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-price.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-price.test.ts new file mode 100644 index 000000000..9e4ec57ba --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-price.test.ts @@ -0,0 +1,236 @@ +/** + * TDD coverage for patch-style custom plan price updates. + * + * Contract under test: + * New behaviors: + * - customize.price + add_items updates the base price and appends items in patch mode. + * - customize.price: null removes the base price in patch mode. + * - customize.price alone updates the base price while preserving feature items. + * - entity-scoped patch price updates only affect the target entity. + * Side effects: + * - Existing-mode patch updates do not expire or replace the customer product. + * - Stripe subscription state stays consistent with the patched customer product. + * + * Pre-impl red: patch setup ignores customize.price, so price-only patches are rejected + * or produce no Stripe delta. + * Post-impl green: patch setup deletes the old base customer price, inserts the custom + * base price when provided, and patches the existing customer product. + */ + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV5, + ApiEntityV2, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +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.concurrent(`${chalk.yellowBright("patch update price: update price and add boolean entitlement")}`, async () => { + const customerId = "patch-update-price-add-item-tdd"; + const pro = products.pro({ items: [] }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 32 }), + add_items: [itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(12); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update price: remove price without replacing customer product")}`, async () => { + const customerId = "patch-update-price-remove-tdd"; + const pro = products.pro({ items: [] }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: null, + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(-20); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +test.concurrent(`${chalk.yellowBright("patch update price: update price only and preserve metered entitlement")}`, async () => { + const customerId = "patch-update-price-only-tdd"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(15); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 100, + usage: 0, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch update price: entity update only affects target entity")}`, async () => { + const customerId = "patch-update-price-entity-tdd"; + const pro = products.pro({ items: [] }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + entity_id: entities[1].id, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 29 }), + add_items: [itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(9); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + + const entity1 = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + + expectFlagCorrect({ + customer: entity1, + featureId: TestFeature.Dashboard, + present: false, + }); + expectFlagCorrect({ + customer: entity2, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: pro.id, + entityId: entities[1].id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-with-others.test.ts b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-with-others.test.ts new file mode 100644 index 000000000..b55ad8d99 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan-patch/patch-update-with-others.test.ts @@ -0,0 +1,429 @@ +/** + * TDD coverage for patch-style updates combined with other update params. + * + * Contract under test: + * New behaviors: + * - Patch customizations compose with adding and removing free trials, including + * correct preview totals, next_cycle previews, and final invoices. + * - Patch customizations while updating product version create a new customer + * product and apply the patch to that new version snapshot. + * - Patch customizations can update a canceling product without clearing its + * canceling state, and the patched state survives uncancel. + * - PUT-style custom update followed by PATCH-style custom update leaves the + * final plan state correct. + * Side effects: + * - Existing-mode patch updates do not expire or replace the customer product. + * - New-version patch updates do expire the original customer product. + * - Stripe subscription state stays consistent with the patched customer product. + * + * Pre-impl red: patchContext may not compose with trial, version, canceling, or + * post-PUT update flows. + * Post-impl green: setup derives the right patch mode/context and compute/execute + * apply the patch through the same billing plan path as normal updates. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + CusProductStatus, + FreeTrialDuration, + ms, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +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 { CusService } from "@/internal/customers/CusService"; + +const expectExpiredCustomerProductCount = async ({ + ctx, + customerId, + productId, + count, +}: { + ctx: Parameters[0]["ctx"]; + customerId: string; + productId: string; + count: number; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + CusProductStatus.Expired, + ], + withEntities: true, + }); + + const expiredCustomerProducts = fullCustomer.customer_products.filter( + (customerProduct) => + customerProduct.product_id === productId && + customerProduct.status === CusProductStatus.Expired, + ); + + expect(expiredCustomerProducts.length).toBe(count); +}; + +test.concurrent(`${chalk.yellowBright("patch with others: add trial and patch item")}`, async () => { + const customerId = "patch-with-others-add-trial"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + free_trial: { + duration_length: 14, + duration_type: FreeTrialDuration.Day, + card_required: true, + }, + add_items: [itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(14), + total: 20, + }); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + latestTotal: preview.total, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch with others: remove trial and patch item")}`, async () => { + const customerId = "patch-with-others-remove-trial"; + const pro = products.proWithTrial({ + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 7, + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + free_trial: null, + add_items: [itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(20); + expectPreviewNextCycleCorrect({ preview, expectDefined: false }); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + await expectProductNotTrialing({ customer, productId: pro.id }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + latestTotal: preview.total, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch with others: version update creates new patched customer product")}`, async () => { + const customerId = "patch-with-others-version"; + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 30 }), + ], + }); + + const updateParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + version: 2, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.dashboard(), itemsV2.monthlyWords({ included: 150 })], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + updateParams, + ); + expect(preview.total).toBe(10); + + await autumnV2_2.subscriptions.update( + updateParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expect(customer.balances[TestFeature.Messages]).toBeUndefined(); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Words, + remaining: 150, + usage: 0, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + latestTotal: preview.total, + }); + await expectExpiredCustomerProductCount({ + ctx, + customerId, + productId: pro.id, + count: 1, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch with others: patch while canceling then uncancel")}`, async () => { + const customerId = "patch-with-others-canceling"; + const free = products.base({ + id: "free", + items: [], + isDefault: true, + }); + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.cancel({ productId: pro.id }), + ], + }); + + const patchParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + add_items: [itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2_2.subscriptions.previewUpdate( + patchParams, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.subscriptions.update( + patchParams, + ); + + const customerAfterPatch = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterPatch, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterPatch, + productId: free.id, + }); + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + cancel_action: "uncancel", + }); + + const customerAfterUncancel = + await autumnV2_2.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + expectFlagCorrect({ + customer: customerAfterUncancel, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("patch with others: put update then patch update")}`, async () => { + const customerId = "patch-with-others-put-then-patch"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const putParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 25 }), + items: [itemsV2.monthlyWords({ included: 120 })], + }, + }; + const putPreview = + await autumnV2_2.subscriptions.previewUpdate( + putParams, + ); + expect(putPreview.total).toBe(5); + await autumnV2_2.subscriptions.update( + putParams, + ); + + const patchParams: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: pro.id, + customize: { + remove_items: [{ feature_id: TestFeature.Words }], + add_items: [ + itemsV2.dashboard(), + itemsV2.monthlyMessages({ included: 200 }), + ], + }, + }; + const patchPreview = + await autumnV2_2.subscriptions.previewUpdate( + patchParams, + ); + expect(patchPreview.total).toBe(0); + await autumnV2_2.subscriptions.update( + patchParams, + ); + + const customer = await autumnV2_2.customers.get(customerId); + expect(customer.balances[TestFeature.Words]).toBeUndefined(); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 200, + usage: 0, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 2, + latestTotal: putPreview.total, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/utils/expectNoExpiredCustomerProducts.ts b/server/tests/integration/billing/utils/expectNoExpiredCustomerProducts.ts new file mode 100644 index 000000000..f5caf3f17 --- /dev/null +++ b/server/tests/integration/billing/utils/expectNoExpiredCustomerProducts.ts @@ -0,0 +1,40 @@ +import { expect } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; + +export const expectNoExpiredCustomerProducts = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: AutumnContext; + customerId: string; + productId?: string; + entityId?: string; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + + const customerProducts = fullCustomer.customer_products.filter( + (customerProduct) => + (productId ? customerProduct.product_id === productId : true) && + (entityId ? customerProduct.entity_id === entityId : true), + ); + const expiredCustomerProducts = customerProducts.filter( + (customerProduct) => customerProduct.status === CusProductStatus.Expired, + ); + + expect( + expiredCustomerProducts.map((customerProduct) => ({ + id: customerProduct.id, + product_id: customerProduct.product_id, + entity_id: customerProduct.entity_id, + status: customerProduct.status, + })), + ).toEqual([]); +}; diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index 96de09cee..d4e585b4f 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -251,6 +251,7 @@ const buildCustomerProduct = ({ internal_product_id: product.internal_id, internal_entity_id: internalEntityId, created_at: now, + updated_at: now, status: CusProductStatus.Active, processor: null, canceled: false, diff --git a/server/tests/unit/redis/create-redis-availability.spec.ts b/server/tests/integration/others/redis/create-redis-availability.test.ts similarity index 100% rename from server/tests/unit/redis/create-redis-availability.spec.ts rename to server/tests/integration/others/redis/create-redis-availability.test.ts diff --git a/server/tests/integration/utils/expectFlagCorrect.ts b/server/tests/integration/utils/expectFlagCorrect.ts index afcc97cb8..08920429f 100644 --- a/server/tests/integration/utils/expectFlagCorrect.ts +++ b/server/tests/integration/utils/expectFlagCorrect.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test"; -import type { ApiCustomerV5 } from "@autumn/shared"; +import type { ApiCustomerV5, ApiEntityV2 } from "@autumn/shared"; export const expectFlagCorrect = ({ customer, @@ -7,15 +7,22 @@ export const expectFlagCorrect = ({ planId, expiresAt, withFeature, + present = true, }: { - customer: ApiCustomerV5; + customer: ApiCustomerV5 | ApiEntityV2; featureId: string; planId?: string | null; expiresAt?: number | null; withFeature?: boolean; + present?: boolean; }) => { const flag = customer.flags[featureId]; + if (!present) { + expect(flag).toBeUndefined(); + return; + } + expect(flag).toBeDefined(); expect(flag.feature_id).toBe(featureId); diff --git a/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts index f6fe3d9ff..a688a318b 100644 --- a/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts +++ b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts @@ -54,23 +54,26 @@ describe(chalk.yellowBright("CreateScheduleParamsV0Schema"), () => { ).toThrow(); }); - test("rejects subscription_id inputs", () => { - expect(() => - CreateScheduleParamsV0Schema.parse({ - customer_id: "cus_123", - phases: [ - { - starts_at: 1_000, - plans: [ - { - plan_id: "pro", - subscription_id: "sub_123", - }, - ], - }, - ], - }), - ).toThrow("subscription_id is not supported for create_schedule"); + test("preserves subscription_id on parsed plan items", () => { + const parsed = CreateScheduleParamsV0Schema.parse({ + customer_id: "cus_123", + phases: [ + { + starts_at: 1_000, + plans: [ + { + plan_id: "pro", + subscription_id: "sub_123", + }, + ], + }, + ], + }); + + const [phase] = parsed.phases; + const [plan] = phase?.plans ?? []; + + expect(plan?.subscription_id).toBe("sub_123"); }); test("rejects empty phases", () => { diff --git a/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts b/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts index 39128edfa..9ea43f986 100644 --- a/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts +++ b/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test"; import type { CreateScheduleBillingContext } from "@autumn/shared"; import { ms } from "@autumn/shared"; import chalk from "chalk"; +import type { DrizzleCli } from "@/db/initDrizzle"; import type Stripe from "stripe"; import { handleCreateScheduleErrors } from "@/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors"; +const db = undefined as unknown as DrizzleCli; + const buildContext = ({ immediateStartsAt, currentEpochMs, @@ -21,57 +24,67 @@ const buildContext = ({ plans: [{ plan_id: "plan" }], }, stripeSubscriptionSchedule: existingSchedule, + productContexts: [], + scheduledPhaseContexts: [], + fullCustomer: { + internal_id: "internal_cus_123", + customer_products: [], + }, }) as unknown as CreateScheduleBillingContext; describe(chalk.yellowBright("handleCreateScheduleErrors"), () => { - test("allows an immediate phase within the tolerance window", () => { + test("allows an immediate phase within the tolerance window", async () => { const now = Date.now(); - expect(() => + await expect( handleCreateScheduleErrors({ + db, billingContext: buildContext({ immediateStartsAt: now, currentEpochMs: now, }), }), - ).not.toThrow(); + ).resolves.toBeUndefined(); }); - test("rejects creation when the immediate phase is far in the past", () => { + test("rejects creation when the immediate phase is far in the past", async () => { const now = Date.now(); - expect(() => + await expect( handleCreateScheduleErrors({ + db, billingContext: buildContext({ immediateStartsAt: now - ms.hours(1), currentEpochMs: now, }), }), - ).toThrow("The first phase must start immediately"); + ).rejects.toThrow("The first phase must start immediately"); }); - test("rejects creation when the immediate phase is far in the future", () => { + test("rejects creation when the immediate phase is far in the future", async () => { const now = Date.now(); - expect(() => + await expect( handleCreateScheduleErrors({ + db, billingContext: buildContext({ immediateStartsAt: now + ms.hours(1), currentEpochMs: now, }), }), - ).toThrow("The first phase must start immediately"); + ).rejects.toThrow("The first phase must start immediately"); }); - test("skips the immediate-start guard on updates (existing schedule)", () => { + test("skips the immediate-start guard on updates (existing schedule)", async () => { // Regression: when editing an existing schedule, the frontend preserves // the persisted starts_at for phase 0. Downstream Stripe execution anchors // the first phase to the schedule's current_phase.start_date anyway, so // the tolerance check should not reject a historical starts_at here. const now = Date.now(); - expect(() => + await expect( handleCreateScheduleErrors({ + db, billingContext: buildContext({ immediateStartsAt: now - ms.days(30), currentEpochMs: now, @@ -80,6 +93,6 @@ describe(chalk.yellowBright("handleCreateScheduleErrors"), () => { } as unknown as Stripe.SubscriptionSchedule, }), }), - ).not.toThrow(); + ).resolves.toBeUndefined(); }); }); diff --git a/server/tests/utils/fixtures/db/customerProducts.ts b/server/tests/utils/fixtures/db/customerProducts.ts index 869fdc93c..d71408594 100644 --- a/server/tests/utils/fixtures/db/customerProducts.ts +++ b/server/tests/utils/fixtures/db/customerProducts.ts @@ -51,6 +51,7 @@ const create = ({ internal_entity_id: internalEntityId ?? null, entity_id: entityId ?? null, created_at: Date.now(), + updated_at: Date.now(), status, canceled: false, starts_at: startsAt ?? Date.now(), diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts index 1a80f34cb..99bc77b11 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -1,6 +1,7 @@ import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1"; import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; +import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter"; import { z } from "zod/v4"; export const CustomizePlanV1Schema = z @@ -10,7 +11,14 @@ export const CustomizePlanV1Schema = z "Override the base price of the plan. Pass null to remove the base price.", }), items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ - description: "Override the items in the plan.", + description: + "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items.", + }), + add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ + description: "Items to add to the plan.", + }), + remove_items: z.array(PlanItemFilterSchema).optional().meta({ + description: "Filters selecting items to remove from the plan.", }), free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({ description: @@ -21,10 +29,23 @@ export const CustomizePlanV1Schema = z (data) => data.items !== undefined || data.price !== undefined || - data.free_trial !== undefined, + data.free_trial !== undefined || + data.add_items !== undefined || + data.remove_items !== undefined, { message: - "When using customize, either items, price, or free_trial must be provided", + "When using customize, at least one of price, items, add_items, remove_items, or free_trial must be provided", + }, + ) + .refine( + (data) => + !( + data.items !== undefined && + (data.add_items !== undefined || data.remove_items !== undefined) + ), + { + message: + "customize.items (PUT-style) cannot be combined with add_items/remove_items (PATCH-style); pick one approach", }, ) .meta({ @@ -40,5 +61,18 @@ export const hasCustomItems = ( customize?: CustomizePlanV1, ): customize is CustomizePlanV1 => { if (!customize) return false; - return customize.price !== undefined || customize.items !== undefined; + return ( + customize.price !== undefined || + customize.items !== undefined || + customize.add_items !== undefined || + customize.remove_items !== undefined + ); }; + +export const isCustomizePlanPatchStyle = ( + customize?: CustomizePlanV1, +): customize is CustomizePlanV1 => + customize?.items === undefined && + (customize?.price !== undefined || + customize?.add_items !== undefined || + customize?.remove_items !== undefined); diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index 47f5651ac..e40d86215 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -43,12 +43,8 @@ export const CreateSchedulePlanSchema = z }), subscription_id: z.string().optional().meta({ description: - "Unsupported for create_schedule today. Requests that include this field will be rejected.", + "A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.", }), - }) - .refine((plan) => plan.subscription_id === undefined, { - message: "subscription_id is not supported for create_schedule", - path: ["subscription_id"], }); export const CreateSchedulePhaseSchema = z.object({ diff --git a/shared/api/products/items/filter/planItemFilter.ts b/shared/api/products/items/filter/planItemFilter.ts new file mode 100644 index 000000000..0d0e8d1e2 --- /dev/null +++ b/shared/api/products/items/filter/planItemFilter.ts @@ -0,0 +1,31 @@ +import { BillingMethod } from "@api/products/components/billingMethod"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { z } from "zod/v4"; + +export const PlanItemFilterSchema = z + .object({ + feature_id: z.string().optional().meta({ + description: "Match items linked to this feature.", + }), + billing_method: z.enum(BillingMethod).optional().meta({ + description: + "Match items with this billing method (prepaid or usage_based).", + }), + interval: z.enum(BillingInterval).optional().meta({ + description: "Match items with this interval.", + }), + }) + .refine( + (filter) => + filter.feature_id !== undefined || + filter.billing_method !== undefined || + filter.interval !== undefined, + { message: "PlanItemFilter must have at least one field set." }, + ) + .meta({ + title: "PlanItemFilter", + description: + "Filter for matching plan items. All provided fields must match (AND).", + }); + +export type PlanItemFilter = z.infer; diff --git a/shared/api/products/items/index.ts b/shared/api/products/items/index.ts index 19a05e0d0..c6f6642fb 100644 --- a/shared/api/products/items/index.ts +++ b/shared/api/products/items/index.ts @@ -2,10 +2,13 @@ import { planItemV0ToProductItem } from "@api/products/items/mappers/planItemV0T export * from "./apiPlanItemV1"; export * from "./crud/createPlanItemParamsV1"; +export * from "./filter/planItemFilter"; export * from "./mappers/planItemV0ToProductItem"; +export * from "./mappers/planItemV1ToPriceAndEnt"; export * from "./mappers/planItemV1ToV0"; export * from "./previousVersions/apiPlanItemV0"; export * from "./previousVersions/apiProductItemV0"; +export * from "./utils/match"; export const apiPlanItem = { map: { diff --git a/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts b/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts new file mode 100644 index 000000000..7ca4b9323 --- /dev/null +++ b/shared/api/products/items/mappers/planItemV1ToPriceAndEnt.ts @@ -0,0 +1,34 @@ +import type { SharedContext } from "../../../../types/sharedContext"; +import { itemToPriceAndEnt } from "../../../../utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt"; +import type { CreatePlanItemParamsV1 } from "../crud/createPlanItemParamsV1"; +import { planItemV0ToProductItem } from "./planItemV0ToProductItem"; +import { planItemV1ToV0 } from "./planItemV1ToV0"; + +export const planItemV1ToPriceAndEnt = ({ + ctx, + item, + orgId, + internalProductId, + isCustom, +}: { + ctx: SharedContext; + item: CreatePlanItemParamsV1; + orgId: string; + internalProductId: string; + isCustom: boolean; +}) => { + const planItemV0 = planItemV1ToV0({ ctx, item }); + const productItem = planItemV0ToProductItem({ ctx, planItem: planItemV0 }); + const feature = ctx.features.find( + (feature) => feature.id === item.feature_id, + ); + + return itemToPriceAndEnt({ + item: productItem, + orgId, + internalProductId, + feature, + isCustom, + features: ctx.features, + }); +}; diff --git a/shared/api/products/items/utils/match/index.ts b/shared/api/products/items/utils/match/index.ts new file mode 100644 index 000000000..c1dd0dba7 --- /dev/null +++ b/shared/api/products/items/utils/match/index.ts @@ -0,0 +1 @@ +export * from "./planItemFilterMatchesCustomerPair"; diff --git a/shared/api/products/items/utils/match/planItemFilterMatchesCustomerPair.ts b/shared/api/products/items/utils/match/planItemFilterMatchesCustomerPair.ts new file mode 100644 index 000000000..30f7faeac --- /dev/null +++ b/shared/api/products/items/utils/match/planItemFilterMatchesCustomerPair.ts @@ -0,0 +1,57 @@ +import { BillingMethod } from "@api/products/components/billingMethod"; +import type { PlanItemFilter } from "@api/products/items/filter/planItemFilter"; +import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels"; +import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels"; +import { BillingType } from "@models/productModels/priceModels/priceEnums"; +import { getBillingType } from "@utils/productUtils/priceUtils"; + +const customerPriceToBillingMethod = ({ + customerPrice, +}: { + customerPrice?: FullCustomerPrice; +}): BillingMethod | undefined => { + if (!customerPrice) return undefined; + + const billingType = getBillingType(customerPrice.price.config); + if (billingType === BillingType.UsageInAdvance) return BillingMethod.Prepaid; + if ( + billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated + ) + return BillingMethod.UsageBased; + + return undefined; +}; + +export const planItemFilterMatchesCustomerPair = ({ + filter, + customerPrice, + customerEntitlement, +}: { + filter: PlanItemFilter; + customerPrice?: FullCustomerPrice; + customerEntitlement?: FullCustomerEntitlement; +}): boolean => { + const featureId = + customerEntitlement?.entitlement.feature.id ?? + customerPrice?.price.config.feature_id ?? + undefined; + + if (filter.feature_id !== undefined && featureId !== filter.feature_id) + return false; + + if (filter.billing_method !== undefined) { + const billingMethod = customerPriceToBillingMethod({ customerPrice }); + if (billingMethod !== filter.billing_method) return false; + } + + if (filter.interval !== undefined) { + const interval = + customerPrice?.price.config.interval ?? + customerEntitlement?.entitlement.interval ?? + undefined; + if (String(interval) !== String(filter.interval)) return false; + } + + return true; +}; diff --git a/shared/models/billingModels/context/createScheduleBillingContext.ts b/shared/models/billingModels/context/createScheduleBillingContext.ts index 42d394971..71a7147b6 100644 --- a/shared/models/billingModels/context/createScheduleBillingContext.ts +++ b/shared/models/billingModels/context/createScheduleBillingContext.ts @@ -10,6 +10,8 @@ export interface ScheduledProductContext { customPrices: Price[]; customEntitlements: Entitlement[]; featureQuantities: FeatureOptions[]; + /** User-provided subscription ID for this scheduled product. */ + externalId?: string; } export interface ScheduledPhaseContext { diff --git a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts index 5d799ad12..a22295a06 100644 --- a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts +++ b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts @@ -2,6 +2,8 @@ import type { CancelAction, Entitlement, FullCusProduct, + FullCustomerEntitlement, + FullCustomerPrice, FullProduct, Price, StripeBillingContextOverride, @@ -14,8 +16,23 @@ export enum UpdateSubscriptionIntent { CancelAction = "cancel_action", None = "none", } + +export type PatchContext = { + originalCustomerProduct: FullCusProduct; + mode: "new" | "existing"; + finalCustomerProduct: FullCusProduct; + fullProduct: FullProduct; + insertCustomerPrices: FullCustomerPrice[]; + insertCustomerEntitlements: FullCustomerEntitlement[]; + deleteCustomerPrices: FullCustomerPrice[]; + deleteCustomerEntitlements: FullCustomerEntitlement[]; + customPrices: Price[]; + customEntitlements: Entitlement[]; +}; + export interface UpdateSubscriptionBillingContext extends BillingContext { customerProduct: FullCusProduct; // target customer product + patchContext?: PatchContext; defaultProduct?: FullProduct; // for cancel flows cancelAction?: CancelAction; // for cancel flows recalculateBalances?: boolean; diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 251664d24..3a672dc89 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -10,6 +10,7 @@ import { FreeTrialSchema, FullCusProductSchema, FullCustomerEntitlementSchema, + FullCustomerPriceSchema, type InsertInvoice, PriceSchema, ReplaceableSchema, @@ -45,15 +46,26 @@ export const CustomerProductUpdateSchema = z.object({ options: z.array(FeatureOptionsSchema).optional(), status: z.enum(CusProductStatus).optional(), billing_cycle_anchor_resets_at: z.number().nullish(), + free_trial_id: z.string().nullish(), + trial_ends_at: z.number().nullish(), // Cancel fields (nullish to support uncancel - setting to null) canceled: z.boolean().nullish(), canceled_at: z.number().nullish(), ended_at: z.number().nullish(), scheduled_ids: z.array(z.string()).optional(), subscription_ids: z.array(z.string()).optional(), + updated_at: z.number().optional(), }), }); +export const PatchCustomerProductSchema = z.object({ + customerProduct: FullCusProductSchema, + insertCustomerEntitlements: z.array(FullCustomerEntitlementSchema), + insertCustomerPrices: z.array(FullCustomerPriceSchema), + deleteCustomerEntitlements: z.array(FullCustomerEntitlementSchema), + deleteCustomerPrices: z.array(FullCustomerPriceSchema), +}); + export const AutumnBillingPlanSchema = z.object({ customerId: z.string(), insertCustomerProducts: z.array(FullCusProductSchema), @@ -81,6 +93,7 @@ export const AutumnBillingPlanSchema = z.object({ insertCustomerEntitlements: z .array(z.custom()) .optional(), + patchCustomerProducts: z.array(PatchCustomerProductSchema).optional(), updateCustomerEntitlements: z .array(UpdateCustomerEntitlementSchema) .optional(), diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index 82dd320e1..8973a2011 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -35,6 +35,7 @@ export const CusProductSchema = z.object({ internal_entity_id: z.string().nullish(), entity_id: z.string().nullish(), created_at: z.number(), + updated_at: z.number().nullable(), // Useful for event-driven subscriptions (and usage-based to check limits) status: z.nativeEnum(CusProductStatus), diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index 195962c74..530698d02 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -28,6 +28,7 @@ export const customerProducts = pgTable( internal_entity_id: text("internal_entity_id"), created_at: numeric({ mode: "number" }), + updated_at: numeric({ mode: "number" }), status: text(), processor: jsonb().$type(), // processors: jsonb().$type(), diff --git a/shared/utils/index.ts b/shared/utils/index.ts index c021c8b08..bcae47dc5 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -53,6 +53,7 @@ export * from "./productUtils/priceUtils"; export * from "./productV2Utils/mapToProductV2"; export * from "./productV2Utils/productItemUtils/classifyItemUtils"; export * from "./productV2Utils/productItemUtils/getItemType"; +export * from "./productV2Utils/productItemUtils/matchPlanItem"; export * from "./productV2Utils/productItemUtils/sortPlanItems"; // Item utils export * from "./productV2Utils/productItemUtils/mapToItem"; diff --git a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts new file mode 100644 index 000000000..66eae1c8a --- /dev/null +++ b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts @@ -0,0 +1,48 @@ +/** biome-ignore-all lint/suspicious/noDoubleEquals: legacy product comparison intentionally uses loose numeric/nullish equality */ + +import { + AllowanceType, + type Entitlement, + type RolloverConfig, +} from "@autumn/shared"; + +const rolloversAreSame = ({ + rollover1, + rollover2, +}: { + rollover1?: RolloverConfig | null; + rollover2?: RolloverConfig | null; +}) => { + if (!rollover1 && !rollover2) return true; + if (!rollover1 && rollover2) return false; + if (rollover1 && !rollover2) return false; + + return ( + rollover1?.max == rollover2?.max && + rollover1?.max_percentage == rollover2?.max_percentage && + rollover1?.duration == rollover2?.duration && + rollover1?.length == rollover2?.length + ); +}; + +export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { + if (ent1.internal_feature_id !== ent2.internal_feature_id) return false; + if (ent1.allowance_type !== ent2.allowance_type) return false; + + const diffs = { + interval: ent1.interval != ent2.interval, + intervalCount: ent1.interval_count != ent2.interval_count, + allowance: + 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, + usageLimit: ent1.usage_limit != ent2.usage_limit, + rollover: !rolloversAreSame({ + rollover1: ent1.rollover, + rollover2: ent2.rollover, + }), + }; + + return !Object.values(diffs).some(Boolean); +}; diff --git a/shared/utils/productUtils/entUtils/index.ts b/shared/utils/productUtils/entUtils/index.ts index aec013c52..3ef9e7755 100644 --- a/shared/utils/productUtils/entUtils/index.ts +++ b/shared/utils/productUtils/entUtils/index.ts @@ -1,3 +1,4 @@ export * from "./classifyEntUtils.js"; +export * from "./compareEnt/entsAreSame.js"; export * from "./enrichEntitlement.js"; export * from "./formatEntUtils.js"; diff --git a/shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame.ts b/shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame.ts new file mode 100644 index 000000000..f1abbf4a3 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame.ts @@ -0,0 +1,86 @@ +/** biome-ignore-all lint/suspicious/noDoubleEquals: legacy product comparison intentionally uses loose numeric/nullish equality */ + +import { + FixedPriceConfigSchema, + type Price, + PriceType, + UsagePriceConfigSchema, + type UsageTier, +} from "@autumn/shared"; + +export const tiersAreSame = (tiers1: UsageTier[], tiers2: UsageTier[]) => { + if (tiers1.length !== tiers2.length) return false; + for (let i = 0; i < tiers1.length; i++) { + const tier1 = tiers1[i]; + const tier2 = tiers2[i]; + + if (i !== tiers1.length - 1 && tier1.to !== tier2.to) return false; + if (tier1.amount !== tier2.amount) return false; + if ((tier1.flat_amount ?? 0) !== (tier2.flat_amount ?? 0)) return false; + } + return true; +}; + +export const pricesAreSame = ( + price1: Price, + price2: Price, + logDifferences = false, +) => { + const config1 = price1.config; + const config2 = price2.config; + + if (config1.type === PriceType.Fixed) { + const fixedConfig1 = FixedPriceConfigSchema.parse(config1); + const fixedConfig2 = FixedPriceConfigSchema.parse(config2); + + const diffs = { + amount: fixedConfig1.amount !== fixedConfig2.amount, + interval: fixedConfig1.interval !== fixedConfig2.interval, + intervalCount: + fixedConfig1.interval_count !== fixedConfig2.interval_count, + }; + + return !Object.values(diffs).some(Boolean); + } + + const usageConfig1 = UsagePriceConfigSchema.parse(config1); + const usageConfig2 = UsagePriceConfigSchema.parse(config2); + + const configDiffs = { + shouldProrate: usageConfig1.should_prorate !== usageConfig2.should_prorate, + billWhen: usageConfig1.bill_when !== usageConfig2.bill_when, + billingUnits: usageConfig1.billing_units !== usageConfig2.billing_units, + interval: usageConfig1.interval !== usageConfig2.interval, + intervalCount: usageConfig1.interval_count !== usageConfig2.interval_count, + internalFeatureId: + usageConfig1.internal_feature_id !== usageConfig2.internal_feature_id, + featureId: usageConfig1.feature_id !== usageConfig2.feature_id, + usageTiers: !tiersAreSame( + usageConfig1.usage_tiers, + usageConfig2.usage_tiers, + ), + }; + + const prorationDiffs = { + onIncrease: + price1.proration_config?.on_increase != + price2.proration_config?.on_increase, + onDecrease: + price1.proration_config?.on_decrease != + price2.proration_config?.on_decrease, + tierBehavior: price1.tier_behavior != price2.tier_behavior, + }; + + const pricesAreDiff = + Object.values(configDiffs).some(Boolean) || + Object.values(prorationDiffs).some(Boolean); + + if (pricesAreDiff && logDifferences) { + console.log("Prices are different", { + configDiffs, + prorationDiffs, + }); + } + + return !pricesAreDiff; +}; diff --git a/shared/utils/productUtils/priceUtils/index.ts b/shared/utils/productUtils/priceUtils/index.ts index 9de0fcd3f..776639020 100644 --- a/shared/utils/productUtils/priceUtils/index.ts +++ b/shared/utils/productUtils/priceUtils/index.ts @@ -5,6 +5,7 @@ import { priceToStripeTiersMode } from "./convertPrice/priceToStripeTiersMode.js export * from "./classifyPrice/priceIsTieredOneOff.js"; export * from "./classifyPriceUtils.js"; +export * from "./comparePrice/pricesAreSame.js"; export * from "./convertAmountUtils.js"; export * from "./convertPrice/priceToStripeTiersMode.js"; export * from "./convertPriceUtils.js"; diff --git a/shared/utils/productV2Utils/productItemUtils/classifyItemUtils.ts b/shared/utils/productV2Utils/productItemUtils/classifyItemUtils.ts index 81d909fb1..32f3312cc 100644 --- a/shared/utils/productV2Utils/productItemUtils/classifyItemUtils.ts +++ b/shared/utils/productV2Utils/productItemUtils/classifyItemUtils.ts @@ -2,7 +2,9 @@ import type { Feature } from "../../../models/featureModels/featureModels.js"; import { type ProductItem, ProductItemFeatureType, + UsageModel, } from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import { itemToUsageType } from "./convertItemUtils.js"; export const isContUseItem = ({ item, @@ -16,3 +18,24 @@ export const isContUseItem = ({ return feature.config?.usage_type === ProductItemFeatureType.ContinuousUse; }; + +export const itemCanBeProrated = ({ + item, + features, +}: { + item: ProductItem; + features: Feature[]; +}) => { + const usageType = itemToUsageType({ item, features }); + + if ( + usageType === ProductItemFeatureType.SingleUse && + item.usage_model === UsageModel.Prepaid + ) { + return true; + } else if (usageType === ProductItemFeatureType.ContinuousUse) { + return true; + } + + return false; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts b/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts new file mode 100644 index 000000000..9cb66087b --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/mappers/itemToPriceAndEnt.ts @@ -0,0 +1,460 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: copied from server item mapper; kept structurally close for parity */ + +import { generateKsuid } from "@autumn/ksuid"; +import { + AllowanceType, + BillingInterval, + BillingType, + BillWhen, + EntInterval, + type Entitlement, + ErrCode, + type Feature, + FeatureType, + FeatureUsageType, + type FixedPriceConfig, + Infinite, + itemToBillingInterval, + itemToEntInterval, + OnDecrease, + OnIncrease, + type Price, + PriceType, + type ProductItem, + shouldProrate, + TierInfinite, + UsageModel, + type UsagePriceConfig, + type UsageTier, +} from "@autumn/shared"; +import { RecaseError } from "../../../../api/errors/base/RecaseError"; +import { entsAreSame } from "../../../productUtils/entUtils/compareEnt/entsAreSame.js"; +import { pricesAreSame } from "../../../productUtils/priceUtils/comparePrice/pricesAreSame.js"; +import { getBillingType } from "../../../productUtils/priceUtils.js"; +import { notNullish, nullish } from "../../../utils.js"; +import { itemCanBeProrated } from "../classifyItemUtils.js"; +import { + isFeatureItem, + isFeaturePriceItem, + isPriceItem, +} from "../getItemType.js"; + +const priceId = () => generateKsuid({ prefix: "pr_" }); +const entitlementId = () => generateKsuid({ prefix: "ent_" }); + +const getResetUsage = ({ + item, + feature, +}: { + item: ProductItem; + feature?: Feature; +}) => { + if (!item.feature_id) { + return undefined; + } + if ( + nullish(item.reset_usage_when_enabled) && + (isFeatureItem(item) || isFeaturePriceItem(item)) && + feature + ) { + return feature?.config?.usage_type === FeatureUsageType.Single; + } + return item.reset_usage_when_enabled; +}; +// ITEM TO PRICE AND ENTITLEMENT +const toPrice = ({ + item, + orgId, + internalProductId, + isCustom, + newVersion, + curPrice, +}: { + item: ProductItem; + orgId: string; + internalProductId: string; + isCustom: boolean; + newVersion?: boolean; + curPrice?: Price; +}) => { + const config: FixedPriceConfig = { + type: PriceType.Fixed, + amount: notNullish(item.price) ? item.price : item.tiers![0].amount, + interval: itemToBillingInterval({ item }) as BillingInterval, + interval_count: item.interval_count || 1, + stripe_product_id: null, + feature_id: null, + internal_feature_id: null, + }; + + let price: Price = { + id: item.price_id || curPrice?.id || priceId(), + created_at: item.created_at || Date.now(), + org_id: orgId, + internal_product_id: internalProductId, + is_custom: isCustom, + config, + proration_config: null, + }; + + if (isCustom || newVersion) { + price = { + ...price, + id: priceId(), + created_at: Date.now(), + }; + } + + return { price, ent: null }; +}; + +export const toFeature = ({ + item, + orgId, + internalFeatureId, + internalProductId, + isCustom, + newVersion, + feature, + curEnt, +}: { + item: ProductItem; + orgId: string; + internalFeatureId: string; + internalProductId?: string; + isCustom: boolean; + newVersion?: boolean; + feature?: Feature; + curEnt?: Entitlement; +}) => { + const isBoolean = feature?.type === FeatureType.Boolean; + + const resetUsage = getResetUsage({ item, feature }); + + let ent: Entitlement = { + id: item.entitlement_id || curEnt?.id || entitlementId(), + org_id: orgId, + created_at: item.created_at || Date.now(), + is_custom: isCustom, + internal_product_id: internalProductId || null, + + internal_feature_id: internalFeatureId, + feature_id: item.feature_id!, + + allowance: item.included_usage === Infinite ? null : item.included_usage!, + allowance_type: isBoolean + ? null + : item.included_usage === Infinite + ? AllowanceType.Unlimited + : AllowanceType.Fixed, + + interval: isBoolean ? null : (itemToEntInterval({ item }) as EntInterval), + interval_count: item.interval_count || 1, + + carry_from_previous: !resetUsage, + entity_feature_id: item.entity_feature_id, + usage_limit: null, + + rollover: item.config?.rollover, + }; + + if (isCustom || newVersion) { + ent = { + ...ent, + id: entitlementId(), + created_at: Date.now(), + }; + } + return { price: null, ent }; +}; + +const toFeatureAndPrice = ({ + item, + orgId, + internalFeatureId, + internalProductId, + isCustom, + curPrice, + curEnt, + newVersion, + features, +}: { + item: ProductItem; + orgId: string; + internalFeatureId: string; + internalProductId: string; + isCustom: boolean; + curPrice?: Price; + curEnt?: Entitlement; + newVersion?: boolean; + features: Feature[]; +}) => { + const resetUsage = getResetUsage({ + item, + feature: features.find((f) => f.id === item.feature_id), + }); + + let ent: Entitlement = { + id: item.entitlement_id || curEnt?.id || entitlementId(), + org_id: orgId, + created_at: item.created_at || Date.now(), + is_custom: isCustom, + internal_product_id: internalProductId, + + internal_feature_id: internalFeatureId, + feature_id: item.feature_id!, + + allowance: (item.included_usage as number) || 0, + allowance_type: AllowanceType.Fixed, + interval: itemToEntInterval({ item }) as EntInterval, + interval_count: item.interval_count || 1, + + carry_from_previous: !resetUsage, + entity_feature_id: item.entity_feature_id, + usage_limit: item.usage_limit || null, + + rollover: item.config?.rollover, + }; + + // Will only create new ent id if + const newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent)); + if (newEnt || newVersion) { + ent = { + ...ent, + id: entitlementId(), + created_at: Date.now(), + }; + } + + const entInterval = itemToEntInterval({ item }); + + const config: UsagePriceConfig = { + type: PriceType.Usage, + + bill_when: + item.usage_model === UsageModel.Prepaid + ? BillWhen.StartOfPeriod + : BillWhen.EndOfPeriod, + + billing_units: item.billing_units || 1, + should_prorate: entInterval === EntInterval.Lifetime, + + internal_feature_id: internalFeatureId, + feature_id: item.feature_id!, + usage_tiers: notNullish(item.price) + ? [ + { + amount: item.price, + to: TierInfinite, + }, + ] + : (item.tiers?.map((x) => { + return { + ...x, + amount: x.amount ?? 0, + }; + }) as UsageTier[]), + interval: itemToBillingInterval({ item }) as BillingInterval, + interval_count: item.interval_count || 1, + }; + + const canProrate = itemCanBeProrated({ item, features }); + + let prorationConfig = null; + if (canProrate) { + const onIncrease = + item.config?.on_increase || OnIncrease.ProrateImmediately; + let onDecrease = item.config?.on_decrease || OnDecrease.Prorate; + + if (shouldProrate(onDecrease) || onDecrease === OnDecrease.Prorate) { + onDecrease = + onIncrease === OnIncrease.ProrateImmediately || + onIncrease === OnIncrease.BillImmediately + ? OnDecrease.ProrateImmediately + : OnDecrease.ProrateNextCycle; + } + + prorationConfig = { + on_increase: onIncrease, + on_decrease: onDecrease, + }; + } + + let price: Price = { + id: item.price_id || curPrice?.id || priceId(), + created_at: item.created_at || Date.now(), + org_id: orgId, + internal_product_id: internalProductId, + is_custom: isCustom, + config, + entitlement_id: ent.id, + proration_config: prorationConfig, + tier_behavior: item.tier_behavior ?? null, + }; + + const billingType = getBillingType(price.config!); + if ( + (billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated) && + price.config!.interval === BillingInterval.OneOff + ) { + throw new RecaseError({ + message: `Usage prices cannot be one-off if not set to prepaid (feature: ${item.feature_id})`, + code: ErrCode.InvalidPrice, + statusCode: 400, + }); + } + + const priceOrEntDifferent = + (curPrice && !pricesAreSame(curPrice, price, true)) || + (curEnt && !entsAreSame(curEnt, ent)); + + if (curPrice && (priceOrEntDifferent || newVersion)) { + const newConfig = price.config as UsagePriceConfig; + const curConfig = curPrice.config as UsagePriceConfig; + newConfig.stripe_meter_id = curConfig.stripe_meter_id; + newConfig.stripe_product_id = curConfig.stripe_product_id; + price.config = newConfig; + } + + if (isCustom || newVersion) { + price = { + ...price, + id: priceId(), + created_at: Date.now(), + }; + } + + return { price, ent }; +}; + +export const itemToPriceAndEnt = ({ + item, + orgId, + internalProductId, + feature, + curPrice, + curEnt, + isCustom, + newVersion, + features, +}: { + item: ProductItem; + orgId: string; + internalProductId: string; + feature?: Feature; + curPrice?: Price; + curEnt?: Entitlement; + isCustom: boolean; + newVersion?: boolean; + features: Feature[]; +}) => { + let newPrice: Price | null = null; + let newEnt: Entitlement | null = null; + + let updatedPrice: Price | null = null; + let updatedEnt: Entitlement | null = null; + + let samePrice: Price | null = null; + let sameEnt: Entitlement | null = null; + + if (isPriceItem(item)) { + const { price } = toPrice({ + item, + orgId, + internalProductId, + isCustom, + newVersion, + curPrice, + }); + + if (!curPrice || newVersion) { + newPrice = price; + } else if (!pricesAreSame(curPrice, price, true)) { + updatedPrice = price; + } else { + samePrice = curPrice; + } + } else if (isFeatureItem(item)) { + if (!feature) { + throw new RecaseError({ + message: `Feature ${item.feature_id} not found`, + code: ErrCode.InvalidRequest, + }); + } + const { ent } = toFeature({ + item, + orgId, + internalFeatureId: feature!.internal_id!, + internalProductId, + isCustom, + newVersion, + feature, + curEnt, + }); + + if (!curEnt || newVersion) { + newEnt = ent; + } + + // Boolean features can't be updated + else if (!entsAreSame(curEnt, ent)) { + updatedEnt = ent; + } else { + sameEnt = curEnt; + } + } else { + if (!feature) { + throw new RecaseError({ + message: `Feature ${item.feature_id} not found`, + code: ErrCode.InvalidRequest, + }); + } + + const { price, ent } = toFeatureAndPrice({ + item, + orgId, + internalFeatureId: feature!.internal_id!, + internalProductId, + isCustom, + curPrice, + curEnt, + newVersion, + features, + }); + + const entSame = curEnt && entsAreSame(curEnt, ent); + + // 1. If no curPrice, price is new + if (!curPrice || newVersion) { + newPrice = price; + } + + // 2. If ent or price aren't same, price is updated + else if (!entSame || !pricesAreSame(curPrice, price, false)) { + updatedPrice = price; + } + + // 3. price is same + else { + samePrice = curPrice; + } + + // 1. If no curEnt, ent is new + if (!curEnt || newVersion) { + newEnt = ent; + } + + // 2. If ent is different, ent is updated + else if (!entSame) { + updatedEnt = ent; + } + + // 3. ent is same + else { + sameEnt = curEnt; + } + } + + return { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt }; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts new file mode 100644 index 000000000..d6db73a01 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts @@ -0,0 +1,38 @@ +import { BillingMethod } from "../../../api/products/components/billingMethod.js"; +import type { PlanItemFilter } from "../../../api/products/items/filter/planItemFilter.js"; +import { + type ProductItem, + UsageModel, +} from "../../../models/productV2Models/productItemModels/productItemModels.js"; + +const usageModelToBillingMethod = ( + usageModel: ProductItem["usage_model"], +): BillingMethod | undefined => { + if (usageModel === UsageModel.Prepaid) return BillingMethod.Prepaid; + if (usageModel === UsageModel.PayPerUse) return BillingMethod.UsageBased; + return undefined; +}; + +export const matchesPlanItemFilter = ({ + item, + filter, +}: { + item: ProductItem; + filter: PlanItemFilter; +}): boolean => { + if (filter.feature_id !== undefined && item.feature_id !== filter.feature_id) + return false; + + if (filter.billing_method !== undefined) { + const itemBillingMethod = usageModelToBillingMethod(item.usage_model); + if (itemBillingMethod !== filter.billing_method) return false; + } + + if ( + filter.interval !== undefined && + String(item.interval) !== String(filter.interval) + ) + return false; + + return true; +};