From c43f4db2806a57cb4e843b07ad76d06f95ad8774 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 6 Jan 2026 14:18:30 +0000 Subject: [PATCH] skeleton complete --- scripts/testGroups/g2.sh | 34 +- server/src/external/autumn/autumnCli.ts | 4 +- .../handleDeferredAutumnBillingPlan.ts | 14 +- .../handleInvoicePaidMetadata.ts | 5 +- .../honoMiddlewares/refreshCacheMiddleware.ts | 4 + server/src/internal/billing/billingRouter.ts | 8 +- .../src/internal/billing/v2/billingContext.ts | 24 +- .../buildAutumnLineItems.ts | 35 +- .../paramsToFeatureOptions.ts | 4 +- .../v2/execute/executeAutumnBillingPlan.ts | 5 +- .../billing/v2/execute/executeBillingPlan.ts | 22 +- .../v2/execute/executeInvoiceAction.ts | 4 +- .../v2/execute/executeStripeSubAction.ts | 47 --- .../handlers/handleApiSubscriptionUpdate.ts | 43 --- .../v2/handlers/handleUpdateSubscription.ts | 43 +++ .../buildStripeInvoiceAction.ts | 33 +- .../buildStripeInvoiceItemsAction.ts | 42 +++ .../buildStripeSubscriptionAction.ts | 12 +- .../buildStripeSubscriptionScheduleAction.ts | 45 ++- .../evaluateStripeBillingPlan.ts | 60 ++++ .../execute/executeStripeBillingPlan.ts | 124 ++----- .../execute/executeStripeInvoiceAction.ts | 80 +++++ .../executeStripeSubscriptionAction.ts | 132 ++++++- .../lineItemsToCreateInvoiceItemsParams.ts | 53 +++ ...ts => lineItemsToInvoiceAddLinesParams.ts} | 6 +- .../utils/invoices/createInvoiceForBilling.ts | 4 +- .../stripe/utils/invoices/stripeInvoiceOps.ts | 21 ++ .../buildStripeSubscriptionItemsUpdate.ts | 58 +-- .../buildSchedulePhases.ts | 4 +- .../buildStripeSubscriptionCreateAction.ts | 10 +- .../subscriptions/stripeSubscriptionOps.ts | 0 .../compute/buildQuantityUpdateLineItems.ts | 25 ++ .../compute/computeQuantityUpdateDetails.ts | 23 +- .../computeInvoiceAction.ts | 8 +- .../computeSubscriptionUpdateCustomPlan.ts | 99 +----- ...puteSubscriptionUpdateFeatureQuantities.ts | 4 +- .../computeSubscriptionUpdateFreeTrialPlan.ts | 8 +- ...uteSubscriptionUpdateNewCustomerProduct.ts | 25 +- .../computeSubscriptionUpdateIntent.ts | 4 +- .../compute/computeSubscriptionUpdatePlan.ts | 14 +- .../computeSubscriptionUpdateQuantityPlan.ts | 8 +- .../calculateProrationAmount.ts | 4 +- .../resolvePriceForQuantityUpdate.ts | 25 +- .../evaluateSubscriptionUpdatePlan.ts | 48 --- .../execute/executeSubscriptionUpdate.ts | 75 ---- .../fetch/fetchTargetCusProductForUpdate.ts | 4 +- ... fetchUpdateSubscriptionBillingContext.ts} | 31 +- .../fetch/updateSubscriptionContextSchema.ts | 15 - .../handleSubscriptionUpdatePreview.ts | 25 -- .../handleUpdateSubscriptionPreview.ts | 39 ++ .../billing/v2/types/autumnBillingPlan.ts | 12 +- .../internal/billing/v2/types/billingPlan.ts | 4 + .../stripeBillingPlan/stripeBillingPlan.ts | 14 +- .../stripeBillingPlan/stripeInvoiceAction.ts | 3 - .../stripeInvoiceItemsAction.ts | 11 + .../v2/types/stripeBillingPlanResult.ts | 7 + .../autumnBillingPlanToFinalFullCustomer.ts | 43 +++ .../v2/utils/getFinalCustomerProductsState.ts | 49 --- .../src/internal/metadata/MetadataService.ts | 7 +- .../utils/insertMetadataFromBillingPlan.ts | 72 ++++ .../src/internal/products/ProductService.ts | 1 - .../checkForMisingBalance.ts | 2 +- .../subscription-update.test.ts | 148 ++++++-- .../utils/expectLatestInvoiceCorrect.ts | 17 + ...cription-update-feature-quantities.test.ts | 36 +- .../changes/V2.0_SubscriptionUpdateChange.ts | 10 +- ...arams.ts => updateSubscriptionV0Params.ts} | 14 +- shared/api/models.ts | 4 +- .../billingModels/invoicingModels/lineItem.ts | 3 + shared/models/otherModels/metadataTable.ts | 3 + .../lineItemBuilders/buildLineItem.ts | 3 + .../lineItemBuilders/usagePriceToLineItem.ts | 9 +- .../lineItemUtils/tiersToLineAmount.ts | 8 +- .../prorationUtils/prorationConfigUtils.ts | 31 +- .../cusProductUtils/filterCusProductUtils.ts | 33 +- .../filterCustomerProductsByActiveStatuses.ts | 12 + ...rCustomerProductsByStripeSubscriptionId.ts | 20 ++ shared/utils/index.ts | 9 +- .../priceUtils/convertPriceUtils.ts | 19 +- .../findPrice/findPriceByFeatureId.ts | 11 + shared/utils/productUtils/priceUtils/index.ts | 5 + .../sheets/SubscriptionUpdateTestSheet.tsx | 332 +++++++++++++++--- vite/vite.config.ts | 1 + 83 files changed, 1522 insertions(+), 895 deletions(-) delete mode 100644 server/src/internal/billing/v2/execute/executeStripeSubAction.ts delete mode 100644 server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts create mode 100644 server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts rename server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/{lineItemsToStripeLines.ts => lineItemsToInvoiceAddLinesParams.ts} (82%) create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/subscriptions/stripeSubscriptionOps.ts delete mode 100644 server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts delete mode 100644 server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts rename server/src/internal/billing/v2/subscriptionUpdate/fetch/{fetchApiSubscriptionUpdateContext.ts => fetchUpdateSubscriptionBillingContext.ts} (80%) delete mode 100644 server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts delete mode 100644 server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.ts create mode 100644 server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceItemsAction.ts create mode 100644 server/src/internal/billing/v2/types/stripeBillingPlanResult.ts create mode 100644 server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts delete mode 100644 server/src/internal/billing/v2/utils/getFinalCustomerProductsState.ts create mode 100644 server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts create mode 100644 server/tests/billing/utils/expectLatestInvoiceCorrect.ts rename shared/api/billing/subscriptionUpdate/{subscriptionUpdateV0Params.ts => updateSubscriptionV0Params.ts} (86%) create mode 100644 shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.ts create mode 100644 shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.ts create mode 100644 shared/utils/productUtils/priceUtils/findPrice/findPriceByFeatureId.ts create mode 100644 shared/utils/productUtils/priceUtils/index.ts diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index f16849227..7321bba60 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -3,20 +3,24 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ - 'server/tests/attach/basic' \ - 'server/tests/attach/upgrade' \ - 'server/tests/attach/downgrade' \ - 'server/tests/attach/free' \ - 'server/tests/attach/addOn' \ - 'server/tests/attach/checkout' \ - 'server/tests/attach/misc' \ - 'server/tests/billing/invoice-action-required' \ - 'server/tests/billing/cancel' \ - 'server/tests/billing/cancel/add-ons' \ - 'server/tests/renew' \ + 'server/tests/billing/subscription-update' \ --max=6 -BUN_PARALLEL_COMPACT \ - 'server/tests/attach/entities' \ - --max=6 - # 'server/tests/external-psps/revenuecat' \ +# BUN_PARALLEL_COMPACT \ +# 'server/tests/attach/basic' \ +# 'server/tests/attach/upgrade' \ +# 'server/tests/attach/downgrade' \ +# 'server/tests/attach/free' \ +# 'server/tests/attach/addOn' \ +# 'server/tests/attach/checkout' \ +# 'server/tests/attach/misc' \ +# 'server/tests/billing/invoice-action-required' \ +# 'server/tests/billing/cancel' \ +# 'server/tests/billing/cancel/add-ons' \ +# 'server/tests/renew' \ +# --max=6 + +# BUN_PARALLEL_COMPACT \ +# 'server/tests/attach/entities' \ +# --max=6 +# # 'server/tests/external-psps/revenuecat' \ diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 6e73415ff..bbb30e272 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -17,8 +17,8 @@ import { type LegacyVersion, type OrgConfig, type RewardRedemption, - type SubscriptionUpdateV0Params, type TrackParams, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; import { defaultApiVersion } from "@tests/constants.js"; import type { @@ -683,7 +683,7 @@ export class AutumnInt { }, }; - subscriptionUpdate = async (params: SubscriptionUpdateV0Params) => { + subscriptionUpdate = async (params: UpdateSubscriptionV0Params) => { const data = await this.post(`/subscriptions/update`, params); return data; }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts index d23357054..7de957283 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts @@ -1,6 +1,7 @@ import type { Metadata } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan"; import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan"; import { MetadataService } from "@/internal/metadata/MetadataService"; @@ -19,9 +20,20 @@ export const handleDeferredAutumnBillingPlan = async ({ return; } + const { billingPlan, billingContext } = data; + + // Execute stripe billing plan + await executeStripeBillingPlan({ + ctx, + stripeBillingPlan: billingPlan.stripe, + autumnBillingPlan: billingPlan.autumn, + billingContext, + resumeFromDeferred: true, + }); + await executeAutumnBillingPlan({ ctx, - autumnBillingPlan: data.autumnBillingPlan, + autumnBillingPlan: billingPlan.autumn, }); await MetadataService.delete({ db, id: metadata.id }); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts index adb20ac97..12393156e 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts @@ -26,7 +26,10 @@ export const handleInvoicePaidMetadata = async ({ if (!metadata) return; // Handle deferred billing plan (v2 flow) - if (metadata.type === MetadataType.DeferredAutumnBillingPlan) { + if ( + metadata.type === MetadataType.InvoiceCheckoutV2 || + metadata.type === MetadataType.InvoiceActionRequiredV2 + ) { await handleDeferredAutumnBillingPlan({ ctx, metadata }); return; } diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 4a4b5076c..ff6969fa2 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -51,6 +51,10 @@ const coreUrls = [ method: "POST", url: "/cancel", }, + { + method: "POST", + url: "/subscriptions/update", + }, ]; /** diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index afbe6a544..312a6240e 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -1,11 +1,11 @@ import { Hono } from "hono"; -import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js"; +import { handleUpdateSubscriptionPreview } from "@/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.js"; import type { HonoEnv } from "../../honoUtils/HonoEnv.js"; import { handleAttach } from "./attach/handleAttach.js"; import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js"; import { handleSetupPayment } from "./handlers/handleSetupPayment.js"; -import { handleApiSubscriptionUpdate } from "./v2/handlers/handleApiSubscriptionUpdate.js"; import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js"; +import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js"; export const billingRouter = new Hono(); @@ -14,8 +14,8 @@ billingRouter.post("/checkout", ...handleCheckoutV2); billingRouter.post("/attach", ...handleAttach); billingRouter.post("/attach_v2", ...handleAttachV2); -billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate); +billingRouter.post("/subscriptions/update", ...handleUpdateSubscription); billingRouter.post( "/subscriptions/preview/update", - ...handleSubscriptionUpdatePreview, + ...handleUpdateSubscriptionPreview, ); diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index 4aac7273e..dd2277213 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -1,6 +1,14 @@ -import type { FullProduct } from "@autumn/shared"; +import type { FullCusProduct, FullProduct } from "@autumn/shared"; import type { FullCustomer } from "@shared/models/cusModels/fullCusModel"; import type Stripe from "stripe"; +import { z } from "zod/v4"; + +export const InvoiceModeSchema = z.object({ + finalizeInvoice: z.boolean().default(false), + enableProductImmediately: z.boolean().default(true), +}); + +export type InvoiceMode = z.infer; export interface BillingContext { fullCustomer: FullCustomer; @@ -10,5 +18,17 @@ export interface BillingContext { stripeSubscription?: Stripe.Subscription; stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; paymentMethod?: Stripe.PaymentMethod; - testClockFrozenTime?: number; + + // Timestamps... + currentEpochMs: number; + billingCycleAnchorMs?: number; + + // Invoice mode + invoiceMode?: InvoiceMode; } + +export interface UpdateSubscriptionBillingContext extends BillingContext { + customerProduct: FullCusProduct; // target customer product +} + +// testClockFrozenTime?: number; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 03b16c867..3cee62d5e 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -3,30 +3,29 @@ import { cusProductToLineItems, type FullCusProduct, } from "@autumn/shared"; - +import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const buildAutumnLineItems = ({ ctx, - newCusProducts, - ongoingCustomerProduct, - billingCycleAnchor, - testClockFrozenTime, + newCustomerProducts, + deletedCustomerProduct, + billingContext, }: { ctx: AutumnContext; - newCusProducts: FullCusProduct[]; - ongoingCustomerProduct?: FullCusProduct; - billingCycleAnchor?: number; - testClockFrozenTime?: number; + newCustomerProducts: FullCusProduct[]; + deletedCustomerProduct?: FullCusProduct; + billingContext: BillingContext; }) => { - const now = testClockFrozenTime ?? Date.now(); - billingCycleAnchor = billingCycleAnchor ?? now; + // billingCycleAnchor = billingCycleAnchor ?? now; + const billingCycleAnchor = billingContext.billingCycleAnchorMs; + const now = billingContext.currentEpochMs; const { org } = ctx; - const arrearLineItems = ongoingCustomerProduct + const arrearLineItems = deletedCustomerProduct ? cusProductToArrearLineItems({ - cusProduct: ongoingCustomerProduct, + cusProduct: deletedCustomerProduct, billingCycleAnchor: billingCycleAnchor!, now, org, @@ -34,9 +33,9 @@ export const buildAutumnLineItems = ({ : []; // Get line items for ongoing cus product - const ongoingLineItems = ongoingCustomerProduct + const deletedLineItems = deletedCustomerProduct ? cusProductToLineItems({ - cusProduct: ongoingCustomerProduct, + cusProduct: deletedCustomerProduct, now, billingCycleAnchor: billingCycleAnchor!, direction: "refund", @@ -44,9 +43,9 @@ export const buildAutumnLineItems = ({ }) : []; - const newLineItems = newCusProducts.flatMap((newCusProduct) => + const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) => cusProductToLineItems({ - cusProduct: newCusProduct, + cusProduct: newCustomerProduct, now, billingCycleAnchor: billingCycleAnchor!, direction: "charge", @@ -56,7 +55,7 @@ export const buildAutumnLineItems = ({ // All items const allLineItems = [ - ...ongoingLineItems, + ...deletedLineItems, ...arrearLineItems, ...newLineItems, ]; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts index 0485ee77b..2d2fe14ee 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts @@ -2,7 +2,7 @@ import type { Feature, FeatureOptions, Price, - SubscriptionUpdateV0Params, + UpdateSubscriptionV0Params, } from "@autumn/shared"; import { roundUsageToNearestBillingUnit } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({ price, feature, }: { - params: SubscriptionUpdateV0Params; + params: UpdateSubscriptionV0Params; price: Price; feature: Feature; }): FeatureOptions | undefined => { diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index ef1f13e5f..3de4a5ce9 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -50,7 +50,10 @@ export const executeAutumnBillingPlan = async ({ await CusProductService.update({ db, cusProductId: updateCustomerProduct.id, - updates: { options: updateCustomerProduct.options }, + updates: { + options: updateCustomerProduct.options, + status: updateCustomerProduct.status, + }, }); } diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index a5ed8a211..523a83889 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -16,24 +16,18 @@ export const executeBillingPlan = async ({ }) => { logBillingPlan({ ctx, billingPlan }); - const enableProductImmediately = - billingPlan.stripe.invoiceAction?.invoiceMode?.enableProductImmediately !== - false; - - await executeStripeBillingPlan({ + const result = await executeStripeBillingPlan({ ctx, - stripeBillingPlan: billingPlan.stripe, - autumnBillingPlan: billingPlan.autumn, + billingPlan, billingContext, }); - // if not enabling product immediately, it will be handled in webhook - if (enableProductImmediately) { - await executeAutumnBillingPlan({ - ctx, - autumnBillingPlan: billingPlan.autumn, - }); - } + if (result.deferred) return result; + + await executeAutumnBillingPlan({ + ctx, + autumnBillingPlan: billingPlan.autumn, + }); return { billingPlan }; }; diff --git a/server/src/internal/billing/v2/execute/executeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeInvoiceAction.ts index 8f706d597..1ea0f349f 100644 --- a/server/src/internal/billing/v2/execute/executeInvoiceAction.ts +++ b/server/src/internal/billing/v2/execute/executeInvoiceAction.ts @@ -4,7 +4,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { InvoiceService } from "@/internal/invoices/InvoiceService"; import { getInvoiceItems } from "@/internal/invoices/invoiceUtils"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice"; -import type { UpdateSubscriptionContext } from "../subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "../billingContext"; import type { SubscriptionUpdateInvoiceAction } from "../typesOld"; /** @@ -22,7 +22,7 @@ export const executeInvoiceAction = async ({ }: { ctx: AutumnContext; invoiceAction: SubscriptionUpdateInvoiceAction; - updateSubscriptionContext: UpdateSubscriptionContext; + updateSubscriptionContext: UpdateSubscriptionBillingContext; }) => { const { stripeCustomer, stripeSubscription, customerProduct } = updateSubscriptionContext; diff --git a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts b/server/src/internal/billing/v2/execute/executeStripeSubAction.ts deleted file mode 100644 index 5876b544f..000000000 --- a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { StripeSubscriptionAction } from "../types/billingPlan"; -import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate"; - -export const executeStripeSubAction = async ({ - ctx, - stripeSubAction, -}: { - ctx: AutumnContext; - stripeSubAction: StripeSubscriptionAction; -}) => { - const { logger } = ctx; - - switch (stripeSubAction.type) { - case "update": - logger.info("Executing Stripe subscription update"); - return await executeStripeSubscriptionUpdate({ - ctx, - stripeSubscriptionAction: stripeSubAction, - }); - - case "create": - logger.info("Executing Stripe subscription create"); - throw new Error("Stripe subscription create not yet implemented"); - - case "cancel_immediately": - logger.info("Executing Stripe subscription cancel immediately"); - throw new Error( - "Stripe subscription cancel immediately not yet implemented", - ); - - case "cancel_at_period_end": - logger.info("Executing Stripe subscription cancel at period end"); - throw new Error( - "Stripe subscription cancel at period end not yet implemented", - ); - - case "none": - logger.info("No Stripe subscription action required"); - return; - - default: - throw new Error( - `Unknown Stripe subscription action type: ${stripeSubAction.type}`, - ); - } -}; diff --git a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts deleted file mode 100644 index ec985b8b3..000000000 --- a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared"; -import { createRoute } from "../../../../honoMiddlewares/routeHandler"; -import { executeBillingPlan } from "../execute/executeBillingPlan"; -import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan"; -import { evaluateSubscriptionUpdatePlan } from "../subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan"; -import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext"; - -export const handleApiSubscriptionUpdate = createRoute({ - body: SubscriptionUpdateV0ParamsSchema, - handler: async (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - - const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({ - ctx, - params: body, - }); - - const autumnBillingPlan = await computeSubscriptionUpdatePlan({ - ctx, - updateSubscriptionContext, - params: body, - }); - - const stripeBillingPlan = evaluateSubscriptionUpdatePlan({ - ctx, - updateSubscriptionContext, - params: body, - autumnBillingPlan, - }); - - await executeBillingPlan({ - ctx, - billingContext: updateSubscriptionContext, - billingPlan: { - autumn: autumnBillingPlan, - stripe: stripeBillingPlan, - }, - }); - - return c.json({ success: true }, 200); - }, -}); diff --git a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts new file mode 100644 index 000000000..4f6f8d1af --- /dev/null +++ b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts @@ -0,0 +1,43 @@ +import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; +import { computeSubscriptionUpdatePlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan"; +import { createRoute } from "../../../../honoMiddlewares/routeHandler"; +import { executeBillingPlan } from "../execute/executeBillingPlan"; +import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan"; +import { fetchUpdateSubscriptionBillingContext } from "../subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext"; + +export const handleUpdateSubscription = createRoute({ + body: UpdateSubscriptionV0ParamsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const updateSubscriptionBillingContext = + await fetchUpdateSubscriptionBillingContext({ + ctx, + params: body, + }); + + const autumnBillingPlan = await computeSubscriptionUpdatePlan({ + ctx, + billingContext: updateSubscriptionBillingContext, + params: body, + }); + + const stripeBillingPlan = evaluateStripeBillingPlan({ + ctx, + billingContext: updateSubscriptionBillingContext, + autumnBillingPlan, + }); + + await executeBillingPlan({ + ctx, + billingContext: updateSubscriptionBillingContext, + billingPlan: { + autumn: autumnBillingPlan, + stripe: stripeBillingPlan, + }, + }); + + return c.json({ success: true }, 200); + }, +}); diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts index 2fd0cf382..78371cd58 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts @@ -1,29 +1,34 @@ import type { LineItem } from "@autumn/shared"; -import type { - InvoiceMode, - StripeInvoiceAction, -} from "../../../types/billingPlan"; -import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines"; +import type { StripeInvoiceAction } from "../../../types/billingPlan"; +import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams"; /** - * Builds a Stripe invoice action from Autumn line items. - * Returns undefined if no line items are provided. + * Builds a StripeInvoiceAction for immediate charges. + * Filters for line items where chargeImmediately === true. + * Returns undefined if no immediate line items are provided. */ export const buildStripeInvoiceAction = ({ - autumnLineItems, - invoiceMode, + lineItems, }: { - autumnLineItems: LineItem[]; - invoiceMode?: InvoiceMode; + lineItems: LineItem[]; }): StripeInvoiceAction | undefined => { - if (autumnLineItems.length === 0) { + const immediateLineItems = lineItems.filter( + (line) => line.chargeImmediately === true, + ); + + if (immediateLineItems.length === 0) { return undefined; } - const lines = lineItemsToStripeLines({ lineItems: autumnLineItems }); + const lines = lineItemsToInvoiceAddLinesParams({ + lineItems: immediateLineItems, + }); + + if (lines.length === 0) { + return undefined; + } return { addLineParams: { lines }, - invoiceMode, }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts new file mode 100644 index 000000000..06f4712b7 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts @@ -0,0 +1,42 @@ +import type { LineItem } from "@autumn/shared"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { StripeInvoiceItemsAction } from "../../../types/billingPlan"; +import { lineItemsToCreateInvoiceItemsParams } from "../utils/invoiceLines/lineItemsToCreateInvoiceItemsParams"; + +/** + * Builds a StripeInvoiceItemsAction for deferred charges (added to next cycle). + * Filters for line items where chargeImmediately === false. + * Returns undefined if no deferred line items are provided. + */ +export const buildStripeInvoiceItemsAction = ({ + lineItems, + billingContext, +}: { + lineItems: LineItem[]; + billingContext: BillingContext; +}): StripeInvoiceItemsAction | undefined => { + const deferredLineItems = lineItems.filter( + (line) => line.chargeImmediately === false, + ); + + if (deferredLineItems.length === 0) { + return undefined; + } + + const stripeCustomerId = billingContext.stripeCustomer?.id; + const stripeSubscriptionId = billingContext.stripeSubscription?.id; + + const createInvoiceItems = lineItemsToCreateInvoiceItemsParams({ + stripeCustomerId, + stripeSubscriptionId, + lineItems: deferredLineItems, + }); + + if (createInvoiceItems.length === 0) { + return undefined; + } + + return { + createInvoiceItems, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts index 39b74bfe1..3a5a558d3 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts @@ -1,9 +1,9 @@ import type { FullCusProduct } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import type { BillingContext } from "@server/internal/billing/v2/billingContext"; import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate"; import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction"; import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction"; -import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; import type { FreeTrialPlan, StripeSubscriptionAction, @@ -13,13 +13,13 @@ import type { export const buildStripeSubscriptionAction = ({ ctx, billingContext, - updatedCustomerProducts, + finalCustomerProducts, stripeSubscriptionScheduleAction, freeTrialPlan, }: { ctx: AutumnContext; - billingContext: UpdateSubscriptionContext; - updatedCustomerProducts: FullCusProduct[]; + billingContext: BillingContext; + finalCustomerProducts: FullCusProduct[]; stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction; freeTrialPlan?: FreeTrialPlan; }): StripeSubscriptionAction | undefined => { @@ -28,11 +28,9 @@ export const buildStripeSubscriptionAction = ({ const subItemsUpdate = buildStripeSubscriptionItemsUpdate({ ctx, billingContext, - updatedCustomerProducts, + finalCustomerProducts, }); - // 1. Compute the action type - // Case 1: No subscription and sub items update is empty -> no action if (!stripeSubscription && subItemsUpdate.length === 0) { return undefined; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts index beeb4acf4..40f4da7e3 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts @@ -1,9 +1,13 @@ import type { FullCusProduct } from "@autumn/shared"; -import { msToSeconds } from "@autumn/shared"; +import { + isCustomerProductOnStripeSubscription, + isCustomerProductOnStripeSubscriptionSchedule, + msToSeconds, + RELEVANT_STATUSES, +} from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type { BillingContext } from "@server/internal/billing/v2/billingContext"; -import { getFinalCustomerProductsState } from "@server/internal/billing/v2/utils/getFinalCustomerProductsState"; -import { buildSchedulePhases } from "@server/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/buildSchedulePhases"; +import { buildSchedulePhases } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases"; import type Stripe from "stripe"; import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan"; @@ -46,31 +50,42 @@ const filterEmptyPhases = ( export const buildStripeSubscriptionScheduleAction = ({ ctx, billingContext, - addCustomerProducts = [], - removeCustomerProducts = [], + finalCustomerProducts, trialEndsAt, nowMs, }: { ctx: AutumnContext; billingContext: BillingContext; - addCustomerProducts?: FullCusProduct[]; - removeCustomerProducts?: FullCusProduct[]; + finalCustomerProducts: FullCusProduct[]; trialEndsAt?: number; nowMs: number; }): StripeSubscriptionScheduleAction | undefined => { - const { stripeSubscriptionSchedule } = billingContext; + const { stripeSubscriptionSchedule, stripeSubscription } = billingContext; - // 1. Get final customer product state - const customerProducts = getFinalCustomerProductsState({ - billingContext, - addCustomerProducts, - removeCustomerProducts, - }); + // 1. Filter customer products by stripe subscription id or stripe subscription schedule ID? + + let customerProducts = stripeSubscription + ? finalCustomerProducts.filter( + (cp) => + isCustomerProductOnStripeSubscription({ + customerProduct: cp, + stripeSubscriptionId: stripeSubscription.id, + }) || + isCustomerProductOnStripeSubscriptionSchedule({ + customerProduct: cp, + stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "", + }), + ) + : []; + + customerProducts = customerProducts.filter((cp) => + RELEVANT_STATUSES.includes(cp.status), + ); const phases = buildSchedulePhases({ ctx, billingContext, - customerProducts, + customerProducts: finalCustomerProducts, trialEndsAt, nowMs, }); diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts new file mode 100644 index 000000000..b5422dce1 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -0,0 +1,60 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction"; +import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer"; +import type { BillingContext } from "../../../billingContext"; +import { buildStripeInvoiceAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceAction"; +import { buildStripeInvoiceItemsAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceItemsAction"; +import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionBuilders/buildStripeSubscriptionAction"; +import type { + AutumnBillingPlan, + StripeBillingPlan, +} from "../../../types/billingPlan"; + +export const evaluateStripeBillingPlan = ({ + ctx, + billingContext, + autumnBillingPlan, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + autumnBillingPlan: AutumnBillingPlan; +}): StripeBillingPlan => { + const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({ + billingContext, + autumnBillingPlan, + }); + + const stripeSubscriptionAction = buildStripeSubscriptionAction({ + ctx, + billingContext, + finalCustomerProducts: finalFullCustomer.customer_products, + }); + + const { autumnLineItems } = autumnBillingPlan; + + const stripeInvoiceAction = buildStripeInvoiceAction({ + lineItems: autumnLineItems, + }); + + const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({ + lineItems: autumnLineItems, + billingContext, + }); + + // Build stripe subscription schedule action + const stripeSubscriptionScheduleAction = + buildStripeSubscriptionScheduleAction({ + ctx, + billingContext, + finalCustomerProducts: finalFullCustomer.customer_products, + trialEndsAt: autumnBillingPlan.freeTrialPlan?.trialEndsAt, + nowMs: billingContext.currentEpochMs, + }); + + return { + subscriptionAction: stripeSubscriptionAction, + invoiceAction: stripeInvoiceAction, + invoiceItemsAction: stripeInvoiceItemsAction, + subscriptionScheduleAction: stripeSubscriptionScheduleAction, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index ccf49da02..9464b65e8 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -1,124 +1,66 @@ -import { MetadataType } from "@autumn/shared"; import type Stripe from "stripe"; -import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; -import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; -import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan"; +import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction"; import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction"; import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; -import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; -import type { - AutumnBillingPlan, - StripeBillingPlan, - StripeInvoiceMetadata, -} from "@/internal/billing/v2/types/billingPlan"; -import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; -import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; -import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils"; -import { MetadataService } from "@/internal/metadata/MetadataService"; -import { generateId } from "@/utils/genUtils"; +import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; +import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; export const executeStripeBillingPlan = async ({ ctx, - stripeBillingPlan, - autumnBillingPlan, + billingPlan, billingContext, + resumeFromDeferred = false, }: { ctx: AutumnContext; - stripeBillingPlan: StripeBillingPlan; - autumnBillingPlan: AutumnBillingPlan; + billingPlan: BillingPlan; billingContext: BillingContext; -}) => { + resumeFromDeferred?: boolean; +}): Promise => { + const { logger } = ctx; const { subscriptionAction: stripeSubscriptionAction, invoiceAction: stripeInvoiceAction, + invoiceItemsAction: stripeInvoiceItemsAction, subscriptionScheduleAction: stripeSubscriptionScheduleAction, - } = stripeBillingPlan; + } = billingPlan.stripe; - const enableProductImmediately = - stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false; - - if (stripeInvoiceAction) { - let invoiceMetadata: StripeInvoiceMetadata | undefined; - - if (!enableProductImmediately) { - const metadataId = generateId("meta"); - await MetadataService.insert({ - db: ctx.db, - data: { - id: metadataId, - type: MetadataType.DeferredAutumnBillingPlan, - data: { - orgId: ctx.org.id, - env: ctx.env, - autumnBillingPlan, - }, - }, - }); - invoiceMetadata = { autumn_metadata_id: metadataId }; - } - - const { invoice } = await createInvoiceForBilling({ + if (stripeInvoiceAction && !resumeFromDeferred) { + const result = await executeStripeInvoiceAction({ ctx, + billingPlan, billingContext, - stripeInvoiceAction, - invoiceMetadata, }); - if (invoice) { - await upsertInvoiceFromBilling({ - ctx, - stripeInvoice: invoice, - fullProducts: billingContext.fullProducts, - fullCustomer: billingContext.fullCustomer, - }); - } + if (result.deferred) return result; + } + + if (stripeInvoiceItemsAction?.createInvoiceItems) { + logger.info( + "[executeStripeBillingPlan] Creating invoice items for next cycle", + ); + await createStripeInvoiceItems({ + ctx, + invoiceItems: stripeInvoiceItemsAction.createInvoiceItems, + }); } let stripeSubscription: Stripe.Subscription | undefined = billingContext.stripeSubscription; if (stripeSubscriptionAction) { - // 1. Insert stripe subscription ID into cache - if (stripeSubscription?.id) { - await addSubIdToCache({ - subId: stripeSubscription.id, - scenario: "billing", - }); - } - - stripeSubscription = await executeStripeSubscriptionAction({ + const result = await executeStripeSubscriptionAction({ ctx, - subscriptionAction: stripeSubscriptionAction, + billingPlan, + billingContext, }); - if (stripeSubscription) { - addStripeSubscriptionIdToBillingPlan({ - autumnBillingPlan, - stripeSubscriptionId: stripeSubscription.id, - }); + if (result?.deferred) return result; - // Add subscription to DB - await upsertSubscriptionFromBilling({ - ctx, - stripeSubscription, - }); - } - - // If the stripe subscription is canceled, remove the subscription from the billing plan - if ( - stripeSubscription && - isStripeSubscriptionCanceled(stripeSubscription) - ) { - removeStripeSubscriptionIdFromBillingPlan({ - autumnBillingPlan, - stripeSubscriptionId: stripeSubscription.id, - }); - - stripeSubscription = undefined; - } + stripeSubscription = result.stripeSubscription; } if (stripeSubscriptionScheduleAction) { @@ -132,9 +74,11 @@ export const executeStripeBillingPlan = async ({ if (stripeSubscriptionSchedule) { addStripeSubscriptionScheduleIdToBillingPlan({ - autumnBillingPlan, + autumnBillingPlan: billingPlan.autumn, stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id, }); } } + + return { stripeInvoice: undefined }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts new file mode 100644 index 000000000..3aff9e14f --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -0,0 +1,80 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; +import type { + BillingPlan, + StripeInvoiceMetadata, +} from "@/internal/billing/v2/types/billingPlan"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; +import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; +import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; + +export const executeStripeInvoiceAction = async ({ + ctx, + billingPlan, + billingContext, +}: { + ctx: AutumnContext; + billingPlan: BillingPlan; + billingContext: BillingContext; +}): Promise => { + const { logger } = ctx; + + let invoiceMetadata: StripeInvoiceMetadata | undefined; + + const { invoiceAction: stripeInvoiceAction } = billingPlan.stripe; + + if (!stripeInvoiceAction) { + return { stripeInvoice: undefined }; + } + + logger.info("[executeStripeInvoiceAction] Creating invoice for billing"); + + const { invoice } = await createInvoiceForBilling({ + ctx, + billingContext, + stripeInvoiceAction, + invoiceMetadata, + }); + + const enableProductAfterInvoice = + billingContext.invoiceMode?.enableProductImmediately === false; + const invoiceActionRequired = invoice.status === "open"; + + // Insert metadata into DB + const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired; + if (deferBillingPlan) { + logger.info( + `Deferring billing plan, enableProductAfterInvoice: ${enableProductAfterInvoice}, invoiceActionRequired: ${invoiceActionRequired}`, + ); + await insertMetadataFromBillingPlan({ + ctx, + billingPlan, + billingContext, + enableProductAfterInvoice, + invoiceActionRequired, + stripeInvoice: invoice, + }); + + return { + stripeInvoice: invoice, + deferred: true, + }; + } + + if (invoice) { + logger.info("[executeStripeInvoiceAction] Upserting invoice from billing"); + await upsertInvoiceFromBilling({ + ctx, + stripeInvoice: invoice, + fullProducts: billingContext.fullProducts, + fullCustomer: billingContext.fullCustomer, + }); + } + + logger.info( + `[executeStripeInvoiceAction] Completed, invoice: ${invoice?.id}`, + ); + + return { stripeInvoice: invoice }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index 092252094..c8dae623a 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -1,13 +1,32 @@ +import { InternalError } from "@autumn/shared"; +import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; +import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; +import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan"; +import type { + BillingPlan, + StripeSubscriptionAction, +} from "@/internal/billing/v2/types/billingPlan"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; +import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; +import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; -export const executeStripeSubscriptionAction = async ({ +type InvoiceModeParams = { + collection_method?: "send_invoice"; + days_until_due?: number; +}; + +const executeSubscriptionOperation = async ({ ctx, subscriptionAction, + invoiceModeParams, }: { ctx: AutumnContext; subscriptionAction: StripeSubscriptionAction; + invoiceModeParams: InvoiceModeParams; }) => { const { org, env } = ctx; const stripeClient = createStripeCli({ org, env }); @@ -16,13 +35,118 @@ export const executeStripeSubscriptionAction = async ({ case "update": return await stripeClient.subscriptions.update( subscriptionAction.stripeSubscriptionId, - subscriptionAction.params, + { + ...subscriptionAction.params, + ...invoiceModeParams, + expand: ["latest_invoice"], + }, ); case "create": - return await stripeClient.subscriptions.create(subscriptionAction.params); + return await stripeClient.subscriptions.create({ + ...subscriptionAction.params, + ...invoiceModeParams, + expand: ["latest_invoice"], + }); case "cancel": return await stripeClient.subscriptions.cancel( subscriptionAction.stripeSubscriptionId, + { + expand: ["latest_invoice"], + }, ); + + default: + throw new InternalError({ + message: "Invalid subscription action type", + }); } }; + +export const executeStripeSubscriptionAction = async ({ + ctx, + billingPlan, + billingContext, +}: { + ctx: AutumnContext; + billingPlan: BillingPlan; + billingContext: BillingContext; +}): Promise => { + // 1. Perform stripe subscription operation + const { subscriptionAction } = billingPlan.stripe; + + if (!subscriptionAction) return {}; + + // Invoice mode: + const invoiceMode = billingContext.invoiceMode; + const invoiceModeParams = invoiceMode + ? { + collection_method: "send_invoice" as const, + days_until_due: 30, + } + : {}; + + let stripeSubscription: Stripe.Subscription | undefined = + await executeSubscriptionOperation({ + ctx, + subscriptionAction, + invoiceModeParams, + }); + + const latestStripeInvoice = + subscriptionAction.type === "create" + ? (stripeSubscription.latest_invoice as Stripe.Invoice) + : undefined; + + // Defer billing plan + const enableProductAfterInvoice = + invoiceMode?.enableProductImmediately === false; + + const invoiceActionRequired = + subscriptionAction.type === "create" && + latestStripeInvoice?.status === "open"; + + const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired; + + if (deferBillingPlan) { + await insertMetadataFromBillingPlan({ + ctx, + billingPlan, + billingContext, + enableProductAfterInvoice, + invoiceActionRequired, + stripeInvoice: latestStripeInvoice, + }); + + return { + stripeInvoice: latestStripeInvoice, + stripeSubscription, + deferred: true, + }; + } + + addStripeSubscriptionIdToBillingPlan({ + autumnBillingPlan: billingPlan.autumn, + stripeSubscriptionId: stripeSubscription.id, + }); + + // Add subscription to DB + await upsertSubscriptionFromBilling({ + ctx, + stripeSubscription, + }); + + // If the stripe subscription is canceled, remove the subscription from the billing plan + if (isStripeSubscriptionCanceled(stripeSubscription)) { + removeStripeSubscriptionIdFromBillingPlan({ + autumnBillingPlan: billingPlan.autumn, + stripeSubscriptionId: stripeSubscription.id, + }); + + stripeSubscription = undefined; + } + + return { + stripeSubscription, + stripeInvoice: latestStripeInvoice, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts new file mode 100644 index 000000000..4dab3e986 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts @@ -0,0 +1,53 @@ +import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared"; +import type Stripe from "stripe"; + +/** + * Converts a single LineItem to Stripe.InvoiceItemCreateParams + */ +const toStripeCreateInvoiceItemParams = ({ + stripeCustomerId, + stripeSubscriptionId, + lineItem, +}: { + stripeCustomerId: string; + stripeSubscriptionId?: string; + lineItem: LineItem; +}): Stripe.InvoiceItemCreateParams => { + const { finalAmount, description, context } = lineItem; + const { billingPeriod, currency } = context; + + return { + customer: stripeCustomerId, + subscription: stripeSubscriptionId, + amount: atmnToStripeAmount({ amount: finalAmount }), + currency, + description, + period: billingPeriod + ? { + start: msToSeconds(billingPeriod.start), + end: msToSeconds(billingPeriod.end), + } + : undefined, + }; +}; + +/** + * Converts an array of LineItems to Stripe.InvoiceItemCreateParams[] + */ +export const lineItemsToCreateInvoiceItemsParams = ({ + stripeCustomerId, + stripeSubscriptionId, + lineItems, +}: { + stripeCustomerId: string; + stripeSubscriptionId?: string; + lineItems: LineItem[]; +}): Stripe.InvoiceItemCreateParams[] => { + return lineItems.map((lineItem) => + toStripeCreateInvoiceItemParams({ + stripeCustomerId, + stripeSubscriptionId, + lineItem, + }), + ); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToStripeLines.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts similarity index 82% rename from server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToStripeLines.ts rename to server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts index 6c1219f11..bc9e9b0f6 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToStripeLines.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts @@ -4,7 +4,7 @@ import type Stripe from "stripe"; /** * Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line */ -export const lineItemToStripeLine = ({ +const toStripeAddLineParams = ({ lineItem, }: { lineItem: LineItem; @@ -27,10 +27,10 @@ export const lineItemToStripeLine = ({ /** * Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[] */ -export const lineItemsToStripeLines = ({ +export const lineItemsToInvoiceAddLinesParams = ({ lineItems, }: { lineItems: LineItem[]; }): Stripe.InvoiceAddLinesParams.Line[] => { - return lineItems.map((lineItem) => lineItemToStripeLine({ lineItem })); + return lineItems.map((lineItem) => toStripeAddLineParams({ lineItem })); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts index 3bee7f926..c71acd748 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts @@ -27,7 +27,9 @@ export const createInvoiceForBilling = async ({ invoiceMetadata?: StripeInvoiceMetadata; }): Promise => { const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); - const { addLineParams, invoiceMode } = stripeInvoiceAction; + const { addLineParams } = stripeInvoiceAction; + const invoiceMode = billingContext.invoiceMode; + const shouldFinalizeInvoice = invoiceMode ? invoiceMode.finalizeInvoice : true; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts index 530d3e91b..35bf1d1ee 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts @@ -1,4 +1,6 @@ import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; // ============================================ // Create Invoice @@ -84,3 +86,22 @@ export const finalizeStripeInvoice = async ({ return invoice; }; + +// ============================================ +// Create Invoice Items +// ============================================ + +export type CreateStripeInvoiceItemsParams = { + ctx: AutumnContext; + invoiceItems: Stripe.InvoiceItemCreateParams[]; +}; + +export const createStripeInvoiceItems = async ({ + ctx, + invoiceItems, +}: CreateStripeInvoiceItemsParams): Promise => { + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + for (const item of invoiceItems) { + await stripeCli.invoiceItems.create(item); + } +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index 6554a4abd..47a63ff2e 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -1,10 +1,10 @@ +import { + filterCustomerProductsByActiveStatuses, + filterCustomerProductsByStripeSubscriptionId, +} from "@autumn/shared"; import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec"; import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; -import { - ACTIVE_STATUSES, - isCustomerProductOnStripeSubscription, -} from "@shared/utils"; import type Stripe from "stripe"; import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils"; import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; @@ -12,33 +12,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec"; -const getFinalCustomerProductsState = ({ - billingContext, - updatedCustomerProducts = [], -}: { - billingContext: BillingContext; - updatedCustomerProducts?: FullCusProduct[]; -}) => { - const { fullCustomer, stripeSubscription } = billingContext; - - const customerProducts = stripeSubscription - ? fullCustomer.customer_products.filter((customerProduct) => - isCustomerProductOnStripeSubscription({ - customerProduct, - stripeSubscriptionId: stripeSubscription.id, - }), - ) - : []; - - return customerProducts.map((customerProduct) => { - const updated = updatedCustomerProducts.find( - (updatedCustomerProduct) => - updatedCustomerProduct.id === customerProduct.id, - ); - return updated ?? customerProduct; - }); -}; - const customerProductsToRecurringStripeItemSpecs = ({ ctx, billingContext, @@ -121,30 +94,31 @@ const stripeItemSpecsToSubItemsUpdate = ({ export const buildStripeSubscriptionItemsUpdate = ({ ctx, billingContext, - updatedCustomerProducts = [], + finalCustomerProducts, }: { ctx: AutumnContext; billingContext: BillingContext; - updatedCustomerProducts?: FullCusProduct[]; + finalCustomerProducts: FullCusProduct[]; }) => { - // 1. Get final customer product state (with updates applied) - let customerProducts = getFinalCustomerProductsState({ - billingContext, - updatedCustomerProducts, + // 1. Filter customer products by stripe subscription id + let customerProducts = filterCustomerProductsByStripeSubscriptionId({ + customerProducts: finalCustomerProducts, + stripeSubscriptionId: billingContext.stripeSubscription?.id, }); - customerProducts = customerProducts.filter((customerProduct) => - ACTIVE_STATUSES.includes(customerProduct.status), - ); + // 2. Filter customer products by active statuses + customerProducts = filterCustomerProductsByActiveStatuses({ + customerProducts, + }); - // 2. Get recurring subscription item array (doesn't include one off items) + // 3. Get recurring subscription item array (doesn't include one off items) const recurringItems = customerProductsToRecurringStripeItemSpecs({ ctx, billingContext, customerProducts, }); - // 3. Diff it with the current subscription items + // 4. Diff it with the current subscription items return stripeItemSpecsToSubItemsUpdate({ billingContext, stripeItemSpecs: recurringItems, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases.ts index f511f0ccc..4a0f2b86d 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases.ts @@ -7,8 +7,8 @@ import { import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; -import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs"; -import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; +import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; +import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; import { buildTransitionPoints } from "./buildTransitionPoints"; /** diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts index d0116fcfd..0b01d5d91 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -25,14 +25,6 @@ export const buildStripeSubscriptionCreateAction = ({ const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required); const isCustomPaymentMethod = paymentMethod?.type === "custom"; - // add_invoice_items: invoiceItems, - // collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", - // days_until_due: invoiceOnly ? 30 : undefined, - // billing cycle anchor? - // discounts? - // expand: ["latest_invoice"], - // metadata? - const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = { customer: stripeCustomer.id, items: subItemsUpdate.map((item) => ({ @@ -42,6 +34,8 @@ export const buildStripeSubscriptionCreateAction = ({ billing_mode: { type: "flexible" }, + collection_method: "charge_automatically", + payment_behavior: isCustomPaymentMethod ? "default_incomplete" : "error_if_incomplete", diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/stripeSubscriptionOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/stripeSubscriptionOps.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts index bf22b7a3a..dd0ea1105 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/buildQuantityUpdateLineItems.ts @@ -9,6 +9,8 @@ import { InternalError, type LineItemContext, orgToCurrency, + priceToProrationConfig, + sumValues, usagePriceToLineItem, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -52,6 +54,18 @@ export const buildQuantityUpdateLineItems = ({ }); } + // Get proration config based on price and direction (upgrade/downgrade) + const isUpgrade = quantityDifferenceForEntitlements > 0; + const { shouldApplyProration, chargeImmediately, skipLineItems } = + priceToProrationConfig({ + price: customerPrice.price, + isUpgrade, + }); + + if (skipLineItems) { + return []; + } + // Clone entitlement with updated quantity for the charge line item const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({ customerEntitlement: prepaidCustomerEntitlement, @@ -76,12 +90,23 @@ export const buildQuantityUpdateLineItems = ({ ...lineItemContext, direction: "refund", }, + shouldProrateOverride: shouldApplyProration, + chargeImmediatelyOverride: chargeImmediately, }); const chargeLineItem = usagePriceToLineItem({ cusEnt: newCustomerEntitlement, context: lineItemContext, + shouldProrateOverride: shouldApplyProration, + chargeImmediatelyOverride: chargeImmediately, }); + // Don't return line items if they sum to 0 + if ( + sumValues([refundLineItem.finalAmount, chargeLineItem.finalAmount]) === 0 + ) { + return []; + } + return [refundLineItem, chargeLineItem]; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts index b7997a0b6..bc2d11273 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts @@ -7,7 +7,7 @@ import { import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "../../billingContext"; import { buildQuantityUpdateLineItems } from "./buildQuantityUpdateLineItems"; import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange"; import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences"; @@ -33,7 +33,7 @@ export const computeQuantityUpdateDetails = ({ }: { ctx: AutumnContext; updatedOptions: FeatureOptions; - updateSubscriptionContext: UpdateSubscriptionContext; + updateSubscriptionContext: UpdateSubscriptionBillingContext; }): QuantityUpdateDetails => { const { customerProduct, currentEpochMs, billingCycleAnchorMs } = updateSubscriptionContext; @@ -78,9 +78,17 @@ export const computeQuantityUpdateDetails = ({ const priceConfiguration = resolvePriceForQuantityUpdate({ customerProduct, updatedOptions, - isUpgrade: quantityDifferences.isUpgrade, }); + const { customerEntitlementId, customerEntitlementBalanceChange } = + calculateCustomerEntitlementChange({ + quantityDifferenceForEntitlements: + quantityDifferences.quantityDifferenceForEntitlements, + billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity, + customerPrice: priceConfiguration.customerPrice, + customerEntitlements: customerProduct.customer_entitlements, + }); + const billingPeriod = getLineItemBillingPeriod({ anchor: billingCycleAnchorMs, price: priceConfiguration.price, @@ -103,15 +111,6 @@ export const computeQuantityUpdateDetails = ({ currentEpochMs, }); - const { customerEntitlementId, customerEntitlementBalanceChange } = - calculateCustomerEntitlementChange({ - quantityDifferenceForEntitlements: - quantityDifferences.quantityDifferenceForEntitlements, - billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity, - customerPrice: priceConfiguration.customerPrice, - customerEntitlements: customerProduct.customer_entitlements, - }); - return { featureId, customerEntitlementId, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts index 612f04bd3..360ee9b12 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeInvoiceAction.ts @@ -5,13 +5,13 @@ import { isCustomerProductOneOff, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; -import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; import type { StripeInvoiceAction, StripeSubscriptionAction, } from "@/internal/billing/v2/types/billingPlan"; -import { lineItemsToStripeLines } from "../../../providers/stripe/utils/invoiceLines/lineItemsToStripeLines"; +import { lineItemsToInvoiceAddLinesParams } from "../../../providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams"; export const computeInvoiceAction = ({ ctx, @@ -21,7 +21,7 @@ export const computeInvoiceAction = ({ billingCycleAnchor, }: { ctx: AutumnContext; - billingContext: UpdateSubscriptionContext; + billingContext: UpdateSubscriptionBillingContext; newCustomerProduct: FullCusProduct; stripeSubscriptionAction?: StripeSubscriptionAction; billingCycleAnchor?: number; @@ -61,7 +61,7 @@ export const computeInvoiceAction = ({ testClockFrozenTime: billingContext.testClockFrozenTime, }); - const addLineParams = lineItemsToStripeLines({ + const addLineParams = lineItemsToInvoiceAddLinesParams({ lineItems: autumnLineItems, }); diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts index ac33f56d2..5d4303e65 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts @@ -1,10 +1,12 @@ import { + CusProductStatus, cusProductToProduct, - type SubscriptionUpdateV0Params, secondsToMs, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext"; +import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan"; import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; @@ -16,8 +18,8 @@ export const computeSubscriptionUpdateCustomPlan = async ({ params, }: { ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; + updateSubscriptionContext: UpdateSubscriptionBillingContext; + params: UpdateSubscriptionV0Params; }) => { const { customerProduct, stripeSubscription } = updateSubscriptionContext; @@ -36,7 +38,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({ customItems: params.items, }); - updateSubscriptionContext.fullProducts.push(customFullProduct); + updateSubscriptionContext.fullProducts = [customFullProduct]; // 2. Compute the custom trial details const { freeTrialPlan, customFreeTrial } = @@ -46,12 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({ fullProduct: customFullProduct, }); - const billingCycleAnchor = + updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt ?? secondsToMs(stripeSubscription?.billing_cycle_anchor); - const nowMs = updateSubscriptionContext.testClockFrozenTime ?? Date.now(); - // 3. Compute the new customer product const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({ ctx, @@ -59,85 +59,24 @@ export const computeSubscriptionUpdateCustomPlan = async ({ params, fullProduct: customFullProduct, freeTrialPlan, - billingCycleAnchor, }); - // Line items? + const autumnLineItems = buildAutumnLineItems({ + ctx, + newCustomerProducts: [newFullCustomerProduct], + deletedCustomerProduct: customerProduct, + billingContext: updateSubscriptionContext, + }); return { insertCustomerProducts: [newFullCustomerProduct], - // updateCustomerProduct: { - // customerProduct: customerProduct, - // updates: { - // status: CusProductStatus.Expired, - // }, - // }, + updateCustomerProduct: { + ...customerProduct, + status: CusProductStatus.Expired, + }, customPrices: customPrices, customEntitlements: customEnts, customFreeTrial: customFreeTrial, - autumnLineItems: [], + autumnLineItems, } satisfies AutumnBillingPlan; - - // 4. Create stripe prices - // const fullCustomer = updateSubscriptionContext.fullCustomer; - // await createStripeResourcesForProducts({ - // ctx, - // fullCustomer, - // fullProducts: [customFullProduct], - // }); - - // // 5. Build subscription schedule action - // const scheduleAction = buildStripeSubscriptionScheduleAction({ - // ctx, - // billingContext: updateSubscriptionContext, - // addCustomerProducts: [newFullCustomerProduct], - // removeCustomerProducts: [customerProduct], - // trialEndsAt: freeTrialPlan.trialEndsAt, - // nowMs, - // }); - - // // 6. Compute Stripe subscription action - // const stripeSubscriptionAction = buildStripeSubscriptionAction({ - // ctx, - // billingContext: updateSubscriptionContext, - // newCustomerProduct: newFullCustomerProduct, - // stripeSubscriptionScheduleAction: scheduleAction, - // freeTrialPlan, - // nowMs, - // }); - - // // 6. Compute subscription schedule action - // const stripeInvoiceAction = computeInvoiceAction({ - // ctx, - // billingContext: updateSubscriptionContext, - // newCustomerProduct: newFullCustomerProduct, - // stripeSubscriptionAction, - // billingCycleAnchor, - // }); - - // const billingPlan: BillingPlan = { - // stripe: { - // subscriptionAction: stripeSubscriptionAction, - // invoiceAction: stripeInvoiceAction, - // subscriptionScheduleAction: scheduleAction, - // }, - - // autumn: { - // freeTrialPlan, - // insertCustomerProducts: [newFullCustomerProduct], - - // updateCustomerProduct: { - // customerProduct: customerProduct, - // updates: { - // status: CusProductStatus.Expired, - // }, - // }, - - // customPrices: customPrices, - // customEntitlements: customEnts, - // customFreeTrial: customFreeTrial, - // }, - // }; - - // return billingPlan; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts index e09d2efd1..421fe6fc3 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts @@ -6,7 +6,7 @@ import { InternalError, isPrepaidPrice, priceToFeature, - type SubscriptionUpdateV0Params, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; @@ -23,7 +23,7 @@ export const computeSubscriptionUpdateFeatureQuantities = ({ ctx: AutumnContext; fullProduct: FullProduct; currentCustomerProduct: FullCusProduct; - params: SubscriptionUpdateV0Params; + params: UpdateSubscriptionV0Params; }) => { const newFeatureQuantities: FeatureOptions[] = []; for (const price of fullProduct.prices) { diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts index 89c0a90c9..8fd95b83a 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts @@ -1,7 +1,7 @@ import type { FreeTrial, FullProduct, - SubscriptionUpdateV0Params, + UpdateSubscriptionV0Params, } from "@autumn/shared"; import { addDuration, @@ -10,7 +10,7 @@ import { secondsToMs, } from "@autumn/shared"; import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; -import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; interface ComputeSubscriptionUpdateTrialDetailsResult { freeTrialPlan: { @@ -25,8 +25,8 @@ export const computeSubscriptionUpdateFreeTrialPlan = ({ params, fullProduct, }: { - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; + updateSubscriptionContext: UpdateSubscriptionBillingContext; + params: UpdateSubscriptionV0Params; fullProduct: FullProduct; }): ComputeSubscriptionUpdateTrialDetailsResult => { const { stripeSubscription, customerProduct, currentEpochMs } = diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts index 0decab44e..feae76d07 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts @@ -1,11 +1,7 @@ -import { - type FullProduct, - InternalError, - type SubscriptionUpdateV0Params, -} from "@autumn/shared"; +import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities"; -import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan"; import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers"; import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages"; @@ -17,29 +13,22 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({ updateSubscriptionContext, fullProduct, freeTrialPlan, - billingCycleAnchor, }: { ctx: AutumnContext; - params: SubscriptionUpdateV0Params; - updateSubscriptionContext: UpdateSubscriptionContext; + params: UpdateSubscriptionV0Params; + updateSubscriptionContext: UpdateSubscriptionBillingContext; fullProduct: FullProduct; freeTrialPlan: FreeTrialPlan; - billingCycleAnchor?: number; }) => { const { customerProduct, fullCustomer, stripeSubscription, stripeSubscriptionSchedule, + billingCycleAnchorMs, currentEpochMs, } = updateSubscriptionContext; - if (!stripeSubscription) { - throw new InternalError({ - message: `[Subscription Update] Stripe subscription not found`, - }); - } - // 1. Get feature quantities const existingUsages = cusProductToExistingUsages({ cusProduct: customerProduct, @@ -67,7 +56,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({ featureQuantities, existingUsages, existingRollovers, - resetCycleAnchor: billingCycleAnchor ?? "now", + resetCycleAnchor: billingCycleAnchorMs ?? "now", now: currentEpochMs, freeTrial: freeTrialPlan.freeTrial ?? null, @@ -76,7 +65,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({ initOptions: { isCustom: true, - subscriptionId: stripeSubscription.id, + subscriptionId: stripeSubscription?.id, subscriptionScheduleId: stripeSubscriptionSchedule?.id, }, }); diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts index 217062849..0ec8bff65 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts @@ -1,4 +1,4 @@ -import type { SubscriptionUpdateV0Params } from "@shared/index"; +import type { UpdateSubscriptionV0Params } from "@shared/index"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"; /** @@ -7,7 +7,7 @@ import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema" * @returns The intent for the subscription update */ export const computeSubscriptionUpdateIntent = ( - params: SubscriptionUpdateV0Params, + params: UpdateSubscriptionV0Params, ): SubscriptionUpdateIntentEnum => { if (params.options?.length && !params.items?.length) return SubscriptionUpdateIntentEnum.UpdateQuantity; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts index be373604a..51e08a2ec 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts @@ -1,10 +1,10 @@ -import type { SubscriptionUpdateV0Params } from "@shared/index"; +import type { UpdateSubscriptionV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan"; import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan"; import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent"; /** @@ -15,12 +15,12 @@ import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateInte */ export const computeSubscriptionUpdatePlan = async ({ ctx, - updateSubscriptionContext, + billingContext, params, }: { ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; + billingContext: UpdateSubscriptionBillingContext; + params: UpdateSubscriptionV0Params; }): Promise => { const intent = computeSubscriptionUpdateIntent(params); @@ -28,13 +28,13 @@ export const computeSubscriptionUpdatePlan = async ({ case SubscriptionUpdateIntentEnum.UpdateQuantity: return computeSubscriptionUpdateQuantityPlan({ ctx, - updateSubscriptionContext, + updateSubscriptionContext: billingContext, params, }); case SubscriptionUpdateIntentEnum.UpdatePlan: return await computeSubscriptionUpdateCustomPlan({ ctx, - updateSubscriptionContext, + updateSubscriptionContext: billingContext, params, }); } diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts index 08e2f4b38..adbeba1bf 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts @@ -1,7 +1,7 @@ -import { InternalError, type SubscriptionUpdateV0Params } from "@autumn/shared"; +import { InternalError, type UpdateSubscriptionV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnBillingPlan } from "../../types/billingPlan"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "../../billingContext"; import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails"; export const computeSubscriptionUpdateQuantityPlan = ({ @@ -10,8 +10,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({ params, }: { ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; + updateSubscriptionContext: UpdateSubscriptionBillingContext; + params: UpdateSubscriptionV0Params; }): AutumnBillingPlan => { const { customerProduct, stripeSubscription } = updateSubscriptionContext; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/calculateProrationAmount.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/calculateProrationAmount.ts index b818552b8..4334308c8 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/calculateProrationAmount.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/calculateProrationAmount.ts @@ -5,7 +5,7 @@ import { priceToLineAmount, } from "@autumn/shared"; import { Decimal } from "decimal.js"; -import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema"; +import type { UpdateSubscriptionBillingContext } from "../../../billingContext"; import type { calculateQuantityDifferences } from "./calculateQuantityDifferences"; import type { resolvePriceForQuantityUpdate } from "./resolvePriceForQuantityUpdate"; @@ -31,7 +31,7 @@ export const calculateProrationAmount = ({ quantityDifferences, billingPeriod, }: { - updateSubscriptionContext: UpdateSubscriptionContext; + updateSubscriptionContext: UpdateSubscriptionBillingContext; previousOptions: FeatureOptions; updatedOptions: FeatureOptions; priceConfiguration: ReturnType; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/resolvePriceForQuantityUpdate.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/resolvePriceForQuantityUpdate.ts index 957d5d376..553265ae2 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/resolvePriceForQuantityUpdate.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/quantityUpdateUtils/resolvePriceForQuantityUpdate.ts @@ -4,40 +4,31 @@ import { type FullCustomerPrice, findCusPriceByFeature, InternalError, - type OnDecrease, - type OnIncrease, type Price, - priceToProrationConfig, type UsagePriceConfig, } from "@autumn/shared"; /** * Extracts and validates price configuration for a quantity update. * - * Determines proration behavior (on_increase/on_decrease) based on quantity change direction. + * Finds the customer price and extracts billing units per quantity. * * @param updatedOptions - New feature options with target quantity * @param customerProduct - Customer product with associated prices - * @param previousOptions - Current feature options - * @returns Price config including proration rules and billing units + * @returns Price config including billing units * @throws {InternalError} When internal_feature_id is missing or price not found */ export const resolvePriceForQuantityUpdate = ({ customerProduct, updatedOptions, - isUpgrade, }: { customerProduct: FullCusProduct; updatedOptions: FeatureOptions; - isUpgrade: boolean; }): { customerPrice: FullCustomerPrice; price: Price; priceConfig: UsagePriceConfig; billingUnitsPerQuantity: number; - prorationBehaviorConfig: OnIncrease | OnDecrease; - shouldApplyProration: boolean; - shouldFinalizeInvoiceImmediately: boolean; } => { if (!updatedOptions.internal_feature_id) { throw new InternalError({ @@ -60,22 +51,10 @@ export const resolvePriceForQuantityUpdate = ({ const priceConfig = price.config as UsagePriceConfig; const billingUnitsPerQuantity = priceConfig.billing_units ?? 1; - const { - prorationBehaviorConfig, - shouldApplyProration, - shouldFinalizeInvoiceImmediately, - } = priceToProrationConfig({ - price, - isUpgrade, - }); - return { customerPrice, price, priceConfig, billingUnitsPerQuantity, - prorationBehaviorConfig, - shouldApplyProration, - shouldFinalizeInvoiceImmediately, }; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts deleted file mode 100644 index cbae7440e..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { SubscriptionUpdateV0Params } from "@shared/index"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { buildStripeInvoiceAction } from "../../providers/stripe/actionBuilders/buildStripeInvoiceAction"; -import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction"; -import type { - AutumnBillingPlan, - StripeBillingPlan, -} from "../../types/billingPlan"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; - -export const evaluateSubscriptionUpdatePlan = ({ - ctx, - updateSubscriptionContext, - params, - autumnBillingPlan, -}: { - ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; - autumnBillingPlan: AutumnBillingPlan; -}): StripeBillingPlan => { - const updatedCustomerProducts = autumnBillingPlan.updateCustomerProduct - ? [autumnBillingPlan.updateCustomerProduct] - : []; - - const stripeSubscriptionAction = buildStripeSubscriptionAction({ - ctx, - billingContext: updateSubscriptionContext, - updatedCustomerProducts, - }); - - const invoiceModeEnabled = params.invoice === true; - - const stripeInvoiceAction = buildStripeInvoiceAction({ - autumnLineItems: autumnBillingPlan.autumnLineItems, - invoiceMode: invoiceModeEnabled - ? { - finalizeInvoice: params.finalize_invoice === true, - enableProductImmediately: params.enable_product_immediately !== false, - } - : undefined, - }); - - return { - subscriptionAction: stripeSubscriptionAction, - invoiceAction: stripeInvoiceAction, - }; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts b/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts deleted file mode 100644 index 4a7021ef9..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { SubscriptionUpdateV0Params } from "@shared/index"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { SubscriptionUpdatePlan } from "@/internal/billing/v2/typesOld"; -import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions"; -import { executeInvoiceAction } from "../../execute/executeInvoiceAction"; -import { executeStripeSubAction } from "../../execute/executeStripeSubAction"; -import { executeStripeSubscriptionUncancel } from "../../execute/executeStripeSubscriptionActions/handleStripeSubscriptionUncancel"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; - -/** - * Executes the subscription update flow in stripe & autumn using context & plan. - * - * @param ctx - The context object. - * @param params - The parameters for the subscription update. - * @param updateSubscriptionContext - The context object for the subscription update. - * @param subscriptionUpdatePlan - The plan for the subscription update. - * @returns A promise that resolves when the subscription update is complete. - */ -export const executeSubscriptionUpdate = async ({ - ctx, - params, - updateSubscriptionContext, - subscriptionUpdatePlan, -}: { - ctx: AutumnContext; - params: SubscriptionUpdateV0Params; - updateSubscriptionContext: UpdateSubscriptionContext; - subscriptionUpdatePlan: SubscriptionUpdatePlan; -}) => { - const { logger } = ctx; - const { customerProduct, stripeSubscription } = updateSubscriptionContext; - const { - ongoingCusProductAction, - stripeSubscriptionAction, - quantityUpdateDetails, - invoiceAction, - shouldUncancelSubscription, - } = subscriptionUpdatePlan; - - if (shouldUncancelSubscription) { - await executeStripeSubscriptionUncancel({ - ctx, - stripeSubscriptionId: stripeSubscription.id, - customerProduct, - }); - } - - logger.info("Executing Stripe subscription action"); - await executeStripeSubAction({ - ctx, - stripeSubAction: stripeSubscriptionAction, - }); - - if (invoiceAction) { - logger.info("Executing invoice action"); - await executeInvoiceAction({ - ctx, - invoiceAction, - updateSubscriptionContext, - }); - } else { - logger.info("No invoice action required"); - } - - logger.info("Executing customer product actions"); - await executeCusProductActions({ - ctx, - ongoingCusProductAction, - newCusProducts: [], - quantityUpdateDetails, - updatedFeatureOptions: params.options || [], - }); - - logger.info("Successfully completed subscription update"); -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchTargetCusProductForUpdate.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchTargetCusProductForUpdate.ts index e098788ed..7a46af869 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchTargetCusProductForUpdate.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchTargetCusProductForUpdate.ts @@ -1,14 +1,14 @@ import { type FullCustomer, isCusProductOnEntity, - type SubscriptionUpdateV0Params, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; export const fetchTargetCusProductForUpdate = ({ params, fullCustomer, }: { - params: SubscriptionUpdateV0Params; + params: UpdateSubscriptionV0Params; fullCustomer: FullCustomer; }) => { const cusProducts = fullCustomer.customer_products; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext.ts similarity index 80% rename from server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts rename to server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext.ts index 8a21d617f..1385b9f89 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext.ts @@ -1,16 +1,17 @@ import { + cusProductToProduct, InternalError, - type SubscriptionUpdateV0Params, secondsToMs, -} from "@shared/index"; + type UpdateSubscriptionV0Params, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling"; import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling"; import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling"; import { CusService } from "../../../../customers/CusService"; +import type { UpdateSubscriptionBillingContext } from "../../billingContext"; import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams"; import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate"; -import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema"; /** * Fetch the context for updating a subscription @@ -18,13 +19,13 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem * @param body - The body of the request * @returns The update subscription context */ -export const fetchApiSubscriptionUpdateContext = async ({ +export const fetchUpdateSubscriptionBillingContext = async ({ ctx, params, }: { ctx: AutumnContext; - params: SubscriptionUpdateV0Params; -}): Promise => { + params: UpdateSubscriptionV0Params; +}): Promise => { const { db, org, env, features } = ctx; const { customer_id: customerId, product_id: productId } = params; @@ -49,6 +50,10 @@ export const fetchApiSubscriptionUpdateContext = async ({ }); } + const fullProduct = cusProductToProduct({ + cusProduct: targetCustomerProduct, + }); + const stripeSubscription = await fetchStripeSubscriptionForBilling({ ctx, fullCus: fullCustomer, @@ -91,16 +96,26 @@ export const fetchApiSubscriptionUpdateContext = async ({ stripeSubscription?.billing_cycle_anchor, ); + // Invoice mode + const invoiceMode = + params?.invoice === true + ? { + finalizeInvoice: params.finalize_invoice === true, + enableProductImmediately: params.enable_product_immediately !== false, + } + : undefined; + return { fullCustomer, - fullProducts: [], + fullProducts: [fullProduct], customerProduct: targetCustomerProduct, stripeSubscription, stripeSubscriptionSchedule, stripeCustomer, paymentMethod, - testClockFrozenTime, + currentEpochMs, billingCycleAnchorMs, + invoiceMode, }; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts deleted file mode 100644 index 10fad8258..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index"; -import type Stripe from "stripe"; - -export type UpdateSubscriptionContext = { - fullCustomer: FullCustomer; - fullProducts: FullProduct[]; - customerProduct: FullCusProduct; - stripeSubscription?: Stripe.Subscription; - stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; - stripeCustomer: Stripe.Customer; - paymentMethod?: Stripe.PaymentMethod; - testClockFrozenTime?: number; - currentEpochMs: number; - billingCycleAnchorMs?: number; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts b/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts deleted file mode 100644 index 0e626fea3..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared"; -import { createRoute } from "../../../../honoMiddlewares/routeHandler"; -import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan"; -import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext"; - -export const handleSubscriptionUpdatePreview = createRoute({ - body: SubscriptionUpdateV0ParamsSchema, - handler: async (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - - const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({ - ctx, - params: body, - }); - - const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({ - ctx, - updateSubscriptionContext, - params: body, - }); - - return c.json(subscriptionUpdatePlan, 200); - }, -}); diff --git a/server/src/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.ts b/server/src/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.ts new file mode 100644 index 000000000..3769764a7 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.ts @@ -0,0 +1,39 @@ +import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; +import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; +import { createRoute } from "../../../../honoMiddlewares/routeHandler"; +import { computeSubscriptionUpdatePlan } from "./compute/computeSubscriptionUpdatePlan"; +import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext"; + +export const handleUpdateSubscriptionPreview = createRoute({ + body: UpdateSubscriptionV0ParamsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const updateSubscriptionBillingContext = + await fetchUpdateSubscriptionBillingContext({ + ctx, + params: body, + }); + + const autumnBillingPlan = await computeSubscriptionUpdatePlan({ + ctx, + billingContext: updateSubscriptionBillingContext, + params: body, + }); + + const stripeBillingPlan = evaluateStripeBillingPlan({ + ctx, + billingContext: updateSubscriptionBillingContext, + autumnBillingPlan, + }); + + return c.json( + { + autumn: autumnBillingPlan, + stripe: stripeBillingPlan, + }, + 200, + ); + }, +}); diff --git a/server/src/internal/billing/v2/types/autumnBillingPlan.ts b/server/src/internal/billing/v2/types/autumnBillingPlan.ts index 40db42b6b..2a59adf71 100644 --- a/server/src/internal/billing/v2/types/autumnBillingPlan.ts +++ b/server/src/internal/billing/v2/types/autumnBillingPlan.ts @@ -6,6 +6,8 @@ import { PriceSchema, } from "@autumn/shared"; import { z } from "zod/v4"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels"; export const FreeTrialPlanSchema = z.object({ @@ -15,13 +17,6 @@ export const FreeTrialPlanSchema = z.object({ export type FreeTrialPlan = z.infer; -export const InvoiceModeSchema = z.object({ - finalizeInvoice: z.boolean().default(false), - enableProductImmediately: z.boolean().default(true), -}); - -export type InvoiceMode = z.infer; - export const UpdateCustomerEntitlementSchema = z.object({ customerEntitlementId: z.string(), balanceChange: z.number(), @@ -50,5 +45,6 @@ export type AutumnBillingPlan = z.infer; export type DeferredAutumnBillingPlanData = { orgId: string; env: AppEnv; - autumnBillingPlan: AutumnBillingPlan; + billingPlan: BillingPlan; + billingContext: BillingContext; }; diff --git a/server/src/internal/billing/v2/types/billingPlan.ts b/server/src/internal/billing/v2/types/billingPlan.ts index b4993509f..d6ac5e794 100644 --- a/server/src/internal/billing/v2/types/billingPlan.ts +++ b/server/src/internal/billing/v2/types/billingPlan.ts @@ -12,6 +12,8 @@ import { StripeBillingPlanSchema, type StripeInvoiceAction, StripeInvoiceActionSchema, + type StripeInvoiceItemsAction, + StripeInvoiceItemsActionSchema, type StripeInvoiceMetadata, type StripeSubscriptionAction, StripeSubscriptionActionSchema, @@ -24,6 +26,7 @@ export { InvoiceModeSchema, StripeBillingPlanSchema, StripeInvoiceActionSchema, + StripeInvoiceItemsActionSchema, StripeSubscriptionActionSchema, StripeSubscriptionScheduleActionSchema, type AutumnBillingPlan, @@ -32,6 +35,7 @@ export { type InvoiceMode, type StripeBillingPlan, type StripeInvoiceAction, + type StripeInvoiceItemsAction, type StripeInvoiceMetadata, type StripeSubscriptionAction, type StripeSubscriptionScheduleAction, diff --git a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts index 469769e0f..ef8ce464e 100644 --- a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts +++ b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts @@ -1,22 +1,28 @@ import { z } from "zod/v4"; import { - StripeInvoiceActionSchema, type StripeInvoiceAction, + StripeInvoiceActionSchema, } from "./stripeInvoiceAction"; import { - StripeSubscriptionActionSchema, + type StripeInvoiceItemsAction, + StripeInvoiceItemsActionSchema, +} from "./stripeInvoiceItemsAction"; +import { type StripeSubscriptionAction, + StripeSubscriptionActionSchema, } from "./stripeSubscriptionAction"; import { - StripeSubscriptionScheduleActionSchema, type StripeSubscriptionScheduleAction, + StripeSubscriptionScheduleActionSchema, } from "./stripeSubscriptionScheduleAction"; export { StripeInvoiceActionSchema, + StripeInvoiceItemsActionSchema, StripeSubscriptionActionSchema, StripeSubscriptionScheduleActionSchema, type StripeInvoiceAction, + type StripeInvoiceItemsAction, type StripeSubscriptionAction, type StripeSubscriptionScheduleAction, }; @@ -25,6 +31,7 @@ export const StripeBillingPlanSchema = z.object({ subscriptionAction: StripeSubscriptionActionSchema.optional(), subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(), invoiceAction: StripeInvoiceActionSchema.optional(), + invoiceItemsAction: StripeInvoiceItemsActionSchema.optional(), }); export type StripeBillingPlan = z.infer; @@ -32,4 +39,3 @@ export type StripeBillingPlan = z.infer; export type StripeInvoiceMetadata = { autumn_metadata_id: string; }; - diff --git a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceAction.ts b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceAction.ts index 4b679136c..e92ca669c 100644 --- a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceAction.ts @@ -1,10 +1,7 @@ import { z } from "zod/v4"; -import { InvoiceModeSchema } from "../autumnBillingPlan"; export const StripeInvoiceActionSchema = z.object({ addLineParams: z.custom(), - invoiceMode: InvoiceModeSchema.optional(), }); export type StripeInvoiceAction = z.infer; - diff --git a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceItemsAction.ts b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceItemsAction.ts new file mode 100644 index 000000000..769bda512 --- /dev/null +++ b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeInvoiceItemsAction.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; + +export const StripeInvoiceItemsActionSchema = z.object({ + createInvoiceItems: z.array( + z.custom(), + ), +}); + +export type StripeInvoiceItemsAction = z.infer< + typeof StripeInvoiceItemsActionSchema +>; diff --git a/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts b/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts new file mode 100644 index 000000000..c3d40f191 --- /dev/null +++ b/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts @@ -0,0 +1,7 @@ +import type Stripe from "stripe"; + +export interface StripeBillingPlanResult { + deferred?: boolean; + stripeInvoice?: Stripe.Invoice; + stripeSubscription?: Stripe.Subscription; +} diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts new file mode 100644 index 000000000..6f4f8b642 --- /dev/null +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -0,0 +1,43 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; + +export const autumnBillingPlanToFinalFullCustomer = ({ + billingContext, + autumnBillingPlan, +}: { + billingContext: BillingContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const finalFullCustomer = structuredClone(billingContext.fullCustomer); + + // 1. Update full customer with new customer products + finalFullCustomer.customer_products = [ + ...finalFullCustomer.customer_products, + ...autumnBillingPlan.insertCustomerProducts, + ]; + + // 2. Update customer product + for (let i = 0; i < finalFullCustomer.customer_products.length; i++) { + const customerProduct = finalFullCustomer.customer_products[i]; + if (customerProduct.id === autumnBillingPlan.updateCustomerProduct?.id) { + finalFullCustomer.customer_products[i] = + autumnBillingPlan.updateCustomerProduct; + } + } + + // 3. Update full customer with updated customer entitlements + if (autumnBillingPlan.updateCustomerEntitlements) { + for (const update of autumnBillingPlan.updateCustomerEntitlements) { + for (const customerProduct of finalFullCustomer.customer_products) { + for (const customerEntitlement of customerProduct.customer_entitlements) { + if (customerEntitlement.id === update.customerEntitlementId) { + customerEntitlement.balance = + (customerEntitlement.balance ?? 0) + update.balanceChange; + } + } + } + } + } + + return finalFullCustomer; +}; diff --git a/server/src/internal/billing/v2/utils/getFinalCustomerProductsState.ts b/server/src/internal/billing/v2/utils/getFinalCustomerProductsState.ts deleted file mode 100644 index 42a928d46..000000000 --- a/server/src/internal/billing/v2/utils/getFinalCustomerProductsState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; -import { - isCustomerProductOnStripeSubscription, - isCustomerProductOnStripeSubscriptionSchedule, -} from "@shared/utils"; -import type { BillingContext } from "@/internal/billing/v2/billingContext"; - -/** - * Gets the final customer product state after applying add/remove operations. - * Filters to only products on the current subscription. - */ -export const getFinalCustomerProductsState = ({ - billingContext, - addCustomerProducts = [], - removeCustomerProducts = [], -}: { - billingContext: BillingContext; - addCustomerProducts?: FullCusProduct[]; - removeCustomerProducts?: FullCusProduct[]; -}): FullCusProduct[] => { - const { fullCustomer, stripeSubscription, stripeSubscriptionSchedule } = - billingContext; - - // Start with existing products on this subscription - let customerProducts = stripeSubscription - ? fullCustomer.customer_products.filter( - (cp) => - isCustomerProductOnStripeSubscription({ - customerProduct: cp, - stripeSubscriptionId: stripeSubscription.id, - }) || - isCustomerProductOnStripeSubscriptionSchedule({ - customerProduct: cp, - stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "", - }), - ) - : []; - - // Remove specified products - customerProducts = customerProducts.filter( - (cp) => - !removeCustomerProducts.some((cpToRemove) => cpToRemove.id === cp.id), - ); - - // Add new products - customerProducts = [...customerProducts, ...addCustomerProducts]; - - return customerProducts; -}; diff --git a/server/src/internal/metadata/MetadataService.ts b/server/src/internal/metadata/MetadataService.ts index 924a71240..72d70fc36 100644 --- a/server/src/internal/metadata/MetadataService.ts +++ b/server/src/internal/metadata/MetadataService.ts @@ -7,9 +7,14 @@ import { import { and, eq } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; +/** + * MetadataService handles CRUD operations for the metadata table. + */ export class MetadataService { static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) { - await db.insert(metadata).values(data); + const insertedMetadata = await db.insert(metadata).values(data).returning(); + + return insertedMetadata[0] as Metadata; } static async get({ db, id }: { db: DrizzleCli; id: string }) { diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts new file mode 100644 index 000000000..92bb2fe68 --- /dev/null +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -0,0 +1,72 @@ +import { generateId, InternalError, MetadataType } from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { + BillingPlan, + DeferredAutumnBillingPlanData, +} from "@/internal/billing/v2/types/billingPlan"; +import { MetadataService } from "../MetadataService"; + +/** + * Creates metadata from a billing plan and optionally links it to a Stripe invoice. + */ +export const insertMetadataFromBillingPlan = async ({ + ctx, + billingPlan, + billingContext, + enableProductAfterInvoice, + invoiceActionRequired, + stripeInvoice, +}: { + ctx: AutumnContext; + billingPlan: BillingPlan; + billingContext: BillingContext; + enableProductAfterInvoice?: boolean; + invoiceActionRequired?: boolean; + stripeInvoice?: Stripe.Invoice; +}) => { + const id = generateId("meta"); + + const type = enableProductAfterInvoice + ? MetadataType.InvoiceCheckoutV2 + : invoiceActionRequired + ? MetadataType.InvoiceActionRequiredV2 + : undefined; + + const data = { + orgId: ctx.org.id, + env: ctx.env, + billingPlan, + billingContext, + } satisfies DeferredAutumnBillingPlanData; + + const metadata = await MetadataService.insert({ + db: ctx.db, + data: { + id, + type, + stripe_invoice_id: stripeInvoice?.id, + data, + }, + }); + + if (!metadata) { + throw new InternalError({ + message: "Failed to insert metadata from billing plan", + }); + } + + // If stripeInvoice, update stripeInvoice with metadata id + if (stripeInvoice) { + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + await stripeCli.invoices.update(stripeInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + } + + return metadata; +}; diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index c7faa4864..7134451f0 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -41,7 +41,6 @@ const parseFreeTrials = ({ return product; }; -// biome-ignore lint/complexity/noStaticOnlyClass: no thanks m8 export class ProductService { static async getByFeature({ db, diff --git a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/checkForMisingBalance.ts b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/checkForMisingBalance.ts index 8578145af..36b9fcb4e 100644 --- a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/checkForMisingBalance.ts +++ b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/checkForMisingBalance.ts @@ -56,7 +56,7 @@ export const checkForMisingBalance = async ({ const feature = cusEnt.entitlement.feature; // 1. If unlimited or boolean feature, skip - if (isUnlimitedCusEnt({ cusEnt }) || isBooleanCusEnt({ cusEnt })) continue; + if (isUnlimitedCusEnt(cusEnt) || isBooleanCusEnt({ cusEnt })) continue; if (isContUseFeature({ feature })) continue; diff --git a/server/tests/billing/subscription-update/subscription-update.test.ts b/server/tests/billing/subscription-update/subscription-update.test.ts index 95963503c..c3963129f 100644 --- a/server/tests/billing/subscription-update/subscription-update.test.ts +++ b/server/tests/billing/subscription-update/subscription-update.test.ts @@ -1,9 +1,17 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +import { + type ApiCustomer, + ApiVersion, + findPriceByFeatureId, + type Price, + priceToLineAmount, +} from "@autumn/shared"; +import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductService } from "@/internal/products/ProductService"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; @@ -30,17 +38,19 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`, const customerId = "sub-update-basic"; const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + const prepaidMessages = constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }); + const prepaidProduct = constructRawProduct({ id: "prepaid_messages", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], + items: [prepaidMessages], }); + let prepaidMessagesPrice: Price | undefined; + beforeAll(async () => { await initCustomerV3({ ctx, @@ -55,6 +65,18 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`, prefix: customerId, }); + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: prepaidProduct.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + prepaidMessagesPrice = findPriceByFeatureId({ + prices: fullProduct.prices, + featureId: TestFeature.Messages, + }); + await autumnV1.attach({ customer_id: customerId, product_id: prepaidProduct.id, @@ -85,6 +107,16 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`, // Should have 240 messages (20 units × 12 billing_units) expect(balance?.purchased_balance).toBe(240); expect(balance?.current_balance).toBe(240); + + // Expect invoices to be created + expectLatestInvoiceCorrect({ + customer, + productId: prepaidProduct.id, + amount: priceToLineAmount({ + price: prepaidMessagesPrice!, + overage: 10 * billingUnits, + }), + }); }); test("should downgrade quantity from 20 to 5 units", async () => { @@ -100,15 +132,29 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`, }); const customer = await autumnV1.customers.get(customerId); + const balance = customer.balances?.[TestFeature.Messages]; // Should have 60 messages (5 units × 12 billing_units) - expect(balance?.purchased_balance).toBe(60); + expect(balance?.current_balance).toBe(60); + + // Expect credit invoice for downgrade (20 -> 5 = -15 units) + const expectedAmount = priceToLineAmount({ + price: prepaidMessagesPrice!, + overage: -15 * billingUnits, + }); + + expectLatestInvoiceCorrect({ + customer, + productId: prepaidProduct.id, + amount: expectedAmount, + }); }); test("should update to same quantity (no-op)", async () => { const beforeUpdate = await autumnV1.customers.get(customerId); const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages]; + const beforeInvoiceCount = beforeUpdate.invoices?.length ?? 0; await autumnV1.subscriptionUpdate({ customer_id: customerId, @@ -123,10 +169,12 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`, const afterUpdate = await autumnV1.customers.get(customerId); const afterBalance = afterUpdate.balances?.[TestFeature.Messages]; + const afterInvoiceCount = afterUpdate.invoices?.length ?? 0; - expect(afterBalance?.purchased_balance).toBe( - beforeBalance?.purchased_balance, - ); + expect(afterBalance?.current_balance).toBe(beforeBalance?.current_balance); + + // No-op should not create a new invoice + expect(afterInvoiceCount).toBe(beforeInvoiceCount); }); }); @@ -134,22 +182,28 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = const customerId = "sub-update-multi"; const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + const messagesBillingUnits = 10; + const wordsBillingUnits = 100; + const multiFeatureProduct = constructRawProduct({ id: "multi_feature_product", items: [ constructPrepaidItem({ featureId: TestFeature.Messages, - billingUnits: 10, + billingUnits: messagesBillingUnits, price: 5, }), constructPrepaidItem({ featureId: TestFeature.Words, - billingUnits: 100, + billingUnits: wordsBillingUnits, price: 10, }), ], }); + let messagesPrice: Price | undefined; + let wordsPrice: Price | undefined; + beforeAll(async () => { await initCustomerV3({ ctx, @@ -164,17 +218,34 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = prefix: customerId, }); + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: multiFeatureProduct.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + messagesPrice = findPriceByFeatureId({ + prices: fullProduct.prices, + featureId: TestFeature.Messages, + }); + + wordsPrice = findPriceByFeatureId({ + prices: fullProduct.prices, + featureId: TestFeature.Words, + }); + await autumnV1.attach({ customer_id: customerId, product_id: multiFeatureProduct.id, options: [ { feature_id: TestFeature.Messages, - quantity: 5 * 10, // 50 messages + quantity: 5 * messagesBillingUnits, // 50 messages }, { feature_id: TestFeature.Words, - quantity: 2 * 100, // 200 words + quantity: 2 * wordsBillingUnits, // 200 words }, ], }); @@ -187,11 +258,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = options: [ { feature_id: TestFeature.Messages, - quantity: 10 * 10, // 100 messages + quantity: 10 * messagesBillingUnits, // 100 messages }, { feature_id: TestFeature.Words, - quantity: 5 * 100, // 500 words + quantity: 5 * wordsBillingUnits, // 500 words }, ], }); @@ -203,6 +274,23 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = const wordsBalance = customer.balances?.[TestFeature.Words]; expect(wordsBalance?.purchased_balance).toBe(500); + + // Invoice total: Messages (5->10 units = +5) + Words (2->5 units = +3) + const expectedAmount = + priceToLineAmount({ + price: messagesPrice!, + overage: 5 * messagesBillingUnits, + }) + + priceToLineAmount({ + price: wordsPrice!, + overage: 3 * wordsBillingUnits, + }); + + expectLatestInvoiceCorrect({ + customer, + productId: multiFeatureProduct.id, + amount: expectedAmount, + }); }); test("should update only one feature while keeping the other unchanged", async () => { @@ -212,11 +300,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = options: [ { feature_id: TestFeature.Messages, - quantity: 15 * 10, // 150 messages (changed) + quantity: 15 * messagesBillingUnits, // 150 messages (changed) }, { feature_id: TestFeature.Words, - quantity: 5 * 100, // 500 words (unchanged) + quantity: 5 * wordsBillingUnits, // 500 words (unchanged) }, ], }); @@ -228,5 +316,25 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () = const wordsBalance = customer.balances?.[TestFeature.Words]; expect(wordsBalance?.purchased_balance).toBe(500); + + // Invoice total: Messages (10->15 units = +5), Words unchanged + const expectedAmount = priceToLineAmount({ + price: messagesPrice!, + overage: 5 * messagesBillingUnits, + }); + + expectLatestInvoiceCorrect({ + customer, + productId: multiFeatureProduct.id, + amount: expectedAmount, + }); + + // Verify Stripe invoice has only 2 line items + const stripeInvoices = await ctx.stripeCli.invoices.list({ + customer: customer.stripe_id!, + }); + + const latestInvoice = stripeInvoices.data[0]; + expect(latestInvoice.lines.data.length).toBe(2); }); }); diff --git a/server/tests/billing/utils/expectLatestInvoiceCorrect.ts b/server/tests/billing/utils/expectLatestInvoiceCorrect.ts new file mode 100644 index 000000000..117d54a56 --- /dev/null +++ b/server/tests/billing/utils/expectLatestInvoiceCorrect.ts @@ -0,0 +1,17 @@ +import { expect } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; + +export const expectLatestInvoiceCorrect = ({ + customer, + productId, + amount, +}: { + customer: ApiCustomer; + productId: string; + amount: number; +}) => { + const invoices = customer.invoices; + + expect(invoices?.[0].total).toBe(amount); + expect(invoices?.[0].plan_ids).toContain(productId); +}; diff --git a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts index bb46a8e3f..a0a917cee 100644 --- a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { SubscriptionUpdateV0Params } from "@autumn/shared"; +import type { UpdateSubscriptionV0Params } from "@autumn/shared"; import { createMockCtx } from "@tests/utils/mockUtils/contextMocks"; import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import { createMockFeature } from "@tests/utils/mockUtils/featureMocks"; @@ -41,7 +41,7 @@ describe( customerPrices: [createMockCustomerPrice({ price })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", // No options provided @@ -75,7 +75,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 50 }], @@ -117,7 +117,7 @@ describe( ], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 200 }], @@ -195,7 +195,7 @@ describe( ], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats @@ -240,7 +240,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200 @@ -276,7 +276,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple @@ -311,7 +311,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000 @@ -367,7 +367,7 @@ describe( customerPrices: [createMockCustomerPrice({ price: oldPrice })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", // No options - should inherit from current @@ -424,7 +424,7 @@ describe( customerPrices: [createMockCustomerPrice({ price: oldPrice })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -472,7 +472,7 @@ describe( // No customerPrices - can't interpret stored quantity }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -511,7 +511,7 @@ describe( const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 50 }], @@ -537,7 +537,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -563,7 +563,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -595,7 +595,7 @@ describe( const fullProduct = createMockFullProduct({ prices: [price] }); const cusProduct = createMockCustomerProduct({ options: [] }); // No current options - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", // No options in params either @@ -637,7 +637,7 @@ describe( customerPrices: [createMockCustomerPrice({ price })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [], // Explicitly empty @@ -680,7 +680,7 @@ describe( customerPrices: [createMockCustomerPrice({ price })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 0 }], @@ -726,7 +726,7 @@ describe( customerPrices: [createMockCustomerPrice({ price })], }); - const params: SubscriptionUpdateV0Params = { + const params: UpdateSubscriptionV0Params = { customer_id: "cus_test", product_id: "prod_test", }; diff --git a/shared/api/billing/subscriptionUpdate/changes/V2.0_SubscriptionUpdateChange.ts b/shared/api/billing/subscriptionUpdate/changes/V2.0_SubscriptionUpdateChange.ts index b3fc289cd..714dc955b 100644 --- a/shared/api/billing/subscriptionUpdate/changes/V2.0_SubscriptionUpdateChange.ts +++ b/shared/api/billing/subscriptionUpdate/changes/V2.0_SubscriptionUpdateChange.ts @@ -4,8 +4,8 @@ import { AffectedResource, defineVersionChange, } from "../../../versionUtils/versionChangeUtils/VersionChange"; -import { SubscriptionUpdateV0ParamsSchema } from "../subscriptionUpdateV0Params"; import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params"; +import { UpdateSubscriptionV0ParamsSchema } from "../UpdateSubscriptionV0Params"; /** * V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format @@ -18,7 +18,7 @@ import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params" * 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings * 3. Added field: `plan_override` for customizations * - * Input: SubscriptionUpdateV0Params (V2.0 format) + * Input: UpdateSubscriptionV0Params (V2.0 format) * Output: SubscriptionUpdateV1Params (V2.1 format) */ @@ -31,15 +31,15 @@ export const V2_0_SubscriptionUpdateChange = defineVersionChange({ ], affectedResources: [AffectedResource.ApiSubscriptionUpdate], newSchema: SubscriptionUpdateV1ParamsSchema, - oldSchema: SubscriptionUpdateV0ParamsSchema, + oldSchema: UpdateSubscriptionV0ParamsSchema, affectsRequest: true, affectsResponse: false, - // Request: V0 (SubscriptionUpdateV0Params) → V1 (SubscriptionUpdateV1Params) + // Request: V0 (UpdateSubscriptionV0Params) → V1 (SubscriptionUpdateV1Params) transformRequest: ({ input, }: { - input: z.infer; + input: z.infer; }): z.infer => { const planId = input.product_id; diff --git a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts b/shared/api/billing/subscriptionUpdate/updateSubscriptionV0Params.ts similarity index 86% rename from shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts rename to shared/api/billing/subscriptionUpdate/updateSubscriptionV0Params.ts index e5a417a5e..d8ebdc033 100644 --- a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts +++ b/shared/api/billing/subscriptionUpdate/updateSubscriptionV0Params.ts @@ -5,7 +5,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo import { CustomerDataSchema } from "../../common/customerData"; import { EntityDataSchema } from "../../models"; -export const ExtSubscriptionUpdateV0ParamsSchema = z.object({ +export const ExtUpdateSubscriptionV0ParamsSchema = z.object({ // Customer / Entity Info customer_id: z.string(), product_id: z.string().nullish(), @@ -33,17 +33,17 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({ prorate_billing: z.boolean().optional(), }); -export const SubscriptionUpdateV0ParamsSchema = - ExtSubscriptionUpdateV0ParamsSchema.extend({ +export const UpdateSubscriptionV0ParamsSchema = + ExtUpdateSubscriptionV0ParamsSchema.extend({ customer_product_id: z.string().optional(), }); -export type ExtSubscriptionUpdateV0Params = z.infer< - typeof ExtSubscriptionUpdateV0ParamsSchema +export type ExtUpdateSubscriptionV0Params = z.infer< + typeof ExtUpdateSubscriptionV0ParamsSchema >; -export type SubscriptionUpdateV0Params = z.infer< - typeof SubscriptionUpdateV0ParamsSchema +export type UpdateSubscriptionV0Params = z.infer< + typeof UpdateSubscriptionV0ParamsSchema >; // keep_existing_plan: true, //disable_plan_switch diff --git a/shared/api/models.ts b/shared/api/models.ts index 58331a75f..b3d336dfb 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -76,9 +76,9 @@ export * from "./billing/checkout/checkoutParamsV1.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutResponseV0.js"; -export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js"; -export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js"; export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js"; +export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js"; + export * from "./common/customerData.js"; export * from "./common/entityData.js"; export * from "./common/pagePaginationSchemas.js"; diff --git a/shared/models/billingModels/invoicingModels/lineItem.ts b/shared/models/billingModels/invoicingModels/lineItem.ts index 764bd6f3f..981529452 100644 --- a/shared/models/billingModels/invoicingModels/lineItem.ts +++ b/shared/models/billingModels/invoicingModels/lineItem.ts @@ -20,6 +20,9 @@ export const LineItemSchema = z stripePriceId: z.string().optional(), stripeProductId: z.string().optional(), + + // Optional - for testing + chargeImmediately: z.boolean().default(true), }) .transform((data) => { return { diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index c534050ca..0d2212057 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -7,6 +7,9 @@ export enum MetadataType { InvoiceCheckout = "invoice_checkout", CheckoutSessionCompleted = "checkout_session_completed", DeferredAutumnBillingPlan = "deferred_autumn_billing_plan", + + InvoiceActionRequiredV2 = "invoice_action_required_v2", + InvoiceCheckoutV2 = "invoice_checkout_v2", } export const metadata = pgTable("metadata", { diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts index d1a6fc643..edb01e997 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts @@ -15,6 +15,7 @@ export const buildLineItem = ({ stripePriceId, stripeProductId, shouldProrate = true, + chargeImmediately = true, }: { context: LineItemContext; amount: number; @@ -22,6 +23,7 @@ export const buildLineItem = ({ stripePriceId?: string; stripeProductId?: string; shouldProrate?: boolean; + chargeImmediately?: boolean; }): LineItem => { // 1. Apply proration if needed if (shouldProrate && context.billingPeriod) { @@ -44,5 +46,6 @@ export const buildLineItem = ({ context, stripePriceId, stripeProductId, + chargeImmediately, } satisfies LineItemCreate); }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 4c459813b..6069425b2 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -17,9 +17,13 @@ import { buildLineItem } from "./buildLineItem"; export const usagePriceToLineItem = ({ cusEnt, context, + shouldProrateOverride, + chargeImmediatelyOverride, }: { cusEnt: FullCusEntWithFullCusProduct; context: LineItemContext; + shouldProrateOverride?: boolean; + chargeImmediatelyOverride?: boolean; }) => { const cusPrice = cusEntToCusPrice({ cusEnt }); const { feature } = context; @@ -75,8 +79,8 @@ export const usagePriceToLineItem = ({ // 5. Get stripe price / product IDs const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt }); - // 6. Should prorate: don't if consumable price - const shouldProrate = !isConsumablePrice(price); + // 6. Should prorate: don't if consumable price (unless override provided) + const shouldProrate = shouldProrateOverride ?? !isConsumablePrice(price); return buildLineItem({ context, @@ -87,5 +91,6 @@ export const usagePriceToLineItem = ({ stripeProductId, shouldProrate, + chargeImmediately: chargeImmediatelyOverride, }); }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index 866cbcbad..6de45f2f2 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -13,8 +13,11 @@ export const tiersToLineAmount = ({ overage: number; billingUnits?: number; }): number => { + const isNegative = overage < 0; + const absoluteOverage = Math.abs(overage); + const roundedOverage = roundUsageToNearestBillingUnit({ - usage: overage, + usage: absoluteOverage, billingUnits, }); @@ -47,5 +50,6 @@ export const tiersToLineAmount = ({ } } - return amount.toDecimalPlaces(10).toNumber(); + const finalAmount = amount.toDecimalPlaces(10).toNumber(); + return isNegative ? -finalAmount : finalAmount; }; diff --git a/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts b/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts index 17a84d753..60031fef9 100644 --- a/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts +++ b/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts @@ -54,18 +54,33 @@ export const shouldProrateDowngradeNow = ({ /** * Determines if a quantity upgrade or downgrade should be prorated. - * @param onIncrease - The proration behavior configuration for the quantity increase. - * @returns True if a quantity upgrade or downgrade should be prorated, false otherwise. + * @param prorationConfig - The proration behavior configuration. + * @returns True if proration should be applied, false otherwise. */ -export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => { - if (!onIncrease) { +export const shouldProrate = (prorationConfig?: OnIncrease | OnDecrease) => { + if (!prorationConfig) { return true; } return ( - onIncrease === OnIncrease.ProrateNextCycle || - onIncrease === OnIncrease.ProrateImmediately || - onIncrease === OnDecrease.ProrateImmediately || - onIncrease === OnDecrease.ProrateNextCycle + prorationConfig === OnIncrease.ProrateNextCycle || + prorationConfig === OnIncrease.ProrateImmediately || + prorationConfig === OnDecrease.ProrateImmediately || + prorationConfig === OnDecrease.ProrateNextCycle + ); +}; + +/** + * Determines if line items should be skipped entirely (no charge or refund). + * @param prorationConfig - The proration behavior configuration. + * @returns True if line items should be skipped, false otherwise. + */ +export const shouldSkipLineItems = ( + prorationConfig: OnIncrease | OnDecrease, +) => { + return ( + prorationConfig === OnDecrease.NoProrations || + prorationConfig === OnDecrease.None || + prorationConfig === OnIncrease.BillNextCycle ); }; diff --git a/shared/utils/cusProductUtils/filterCusProductUtils.ts b/shared/utils/cusProductUtils/filterCusProductUtils.ts index 5aa279ea8..f4df0c7a2 100644 --- a/shared/utils/cusProductUtils/filterCusProductUtils.ts +++ b/shared/utils/cusProductUtils/filterCusProductUtils.ts @@ -1,4 +1,7 @@ -import { CusProductStatus, type FullCustomer } from "../../index.js"; +import { + type FullCustomer, + filterCustomerProductsByActiveStatuses, +} from "../../index.js"; import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; @@ -96,16 +99,6 @@ export const filterOutEntitiesFromCusProducts = ({ return finalCusProducts; }; -export const getActiveCusProducts = ({ - customer, -}: { - customer: FullCustomer; -}): FullCusProduct[] => { - return customer.customer_products.filter( - (p: FullCusProduct) => p.status === CusProductStatus.Active, - ); -}; - export const isProductAlreadyEnabled = ({ productId, customer, @@ -115,7 +108,9 @@ export const isProductAlreadyEnabled = ({ customer: FullCustomer; entityId?: string; }) => { - return getActiveCusProducts({ customer }).some((cp: FullCusProduct) => { + return filterCustomerProductsByActiveStatuses({ + customerProducts: customer.customer_products, + }).some((cp: FullCusProduct) => { // Check if product matches and is not an add-on if (cp.product_id !== productId || cp.product.is_add_on) { return false; @@ -142,17 +137,3 @@ export const isProductAlreadyEnabled = ({ return false; }); }; - -export const filterCusProductsBySubId = ({ - cusProducts, - subId, -}: { - cusProducts: FullCusProduct[]; - subId?: string; -}): FullCusProduct[] => { - if (!subId) return []; - - return cusProducts.filter((cp: FullCusProduct) => - cp.subscription_ids?.includes(subId), - ); -}; diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.ts new file mode 100644 index 000000000..252dbd757 --- /dev/null +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.ts @@ -0,0 +1,12 @@ +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; +import { ACTIVE_STATUSES } from "@utils/cusProductUtils/cusProductConstants"; + +export const filterCustomerProductsByActiveStatuses = ({ + customerProducts, +}: { + customerProducts: FullCusProduct[]; +}) => { + return customerProducts.filter((customerProduct) => + ACTIVE_STATUSES.includes(customerProduct.status), + ); +}; diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.ts new file mode 100644 index 000000000..1578937e6 --- /dev/null +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.ts @@ -0,0 +1,20 @@ +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; + +export const filterCustomerProductsByStripeSubscriptionId = ({ + customerProducts, + stripeSubscriptionId, +}: { + customerProducts: FullCusProduct[]; + stripeSubscriptionId?: string; +}) => { + return customerProducts.filter((customerProduct) => { + if (!stripeSubscriptionId) { + return ( + customerProduct.subscription_ids?.length === 0 || + !customerProduct.subscription_ids + ); + } + + return customerProduct.subscription_ids?.includes(stripeSubscriptionId); + }); +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 99ad846de..568be00e7 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -39,7 +39,9 @@ export * from "./cusProductUtils/cusProductConstants.js"; export * from "./cusProductUtils/cusProductUtils.js"; export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js"; export * from "./cusProductUtils/filterCusProductUtils.js"; -export * from "./cusProductUtils/filterCusProductUtils.js"; +export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.js"; +export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js"; + export * from "./cusProductUtils/getCusProductFromCustomer.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; // Cus utils @@ -67,10 +69,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js"; export * from "./productUtils/freeTrialUtils/initFreeTrial.js"; export * from "./productUtils/freeTrialUtils.js"; export * from "./productUtils/isProductUpgrade.js"; -export * from "./productUtils/priceUtils/classifyPriceUtils.js"; -export * from "./productUtils/priceUtils/convertAmountUtils.js"; -export * from "./productUtils/priceUtils/convertPriceUtils.js"; -export * from "./productUtils/priceUtils/formatPriceUtils.js"; +export * from "./productUtils/priceUtils/index.js"; export * from "./productUtils/priceUtils.js"; export * from "./productV2Utils/mapToProductV2.js"; export * from "./productV2Utils/productItemUtils/classifyItemUtils.js"; diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index 5e4cbfe33..db488831d 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -6,7 +6,11 @@ import { OnDecrease, OnIncrease, } from "@models/productV2Models/productItemModels/productItemEnums"; -import { shouldBillNow, shouldProrate } from "@utils/billingUtils"; +import { + shouldBillNow, + shouldProrate, + shouldSkipLineItems, +} from "@utils/billingUtils"; import { priceToEnt } from "@utils/productUtils/convertProductUtils"; export const priceToFeature = ({ @@ -43,20 +47,17 @@ export const priceToProrationConfig = ({ }): { prorationBehaviorConfig: OnIncrease | OnDecrease; shouldApplyProration: boolean; - shouldFinalizeInvoiceImmediately: boolean; + chargeImmediately: boolean; + skipLineItems: boolean; } => { const prorationBehaviorConfig = isUpgrade ? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately) : (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately); - const shouldApplyProration = shouldProrate(prorationBehaviorConfig); - const shouldFinalizeInvoiceImmediately = shouldBillNow( - prorationBehaviorConfig, - ); - return { prorationBehaviorConfig, - shouldApplyProration, - shouldFinalizeInvoiceImmediately, + shouldApplyProration: shouldProrate(prorationBehaviorConfig), + chargeImmediately: shouldBillNow(prorationBehaviorConfig), + skipLineItems: shouldSkipLineItems(prorationBehaviorConfig), }; }; diff --git a/shared/utils/productUtils/priceUtils/findPrice/findPriceByFeatureId.ts b/shared/utils/productUtils/priceUtils/findPrice/findPriceByFeatureId.ts new file mode 100644 index 000000000..cedd83c10 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/findPrice/findPriceByFeatureId.ts @@ -0,0 +1,11 @@ +import type { Price } from "@models/productModels/priceModels/priceModels"; + +export const findPriceByFeatureId = ({ + prices, + featureId, +}: { + prices: Price[]; + featureId: string; +}) => { + return prices.find((p) => p.config.feature_id === featureId); +}; diff --git a/shared/utils/productUtils/priceUtils/index.ts b/shared/utils/productUtils/priceUtils/index.ts new file mode 100644 index 000000000..c3854f1c1 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/index.ts @@ -0,0 +1,5 @@ +export * from "./classifyPriceUtils.js"; +export * from "./convertAmountUtils.js"; +export * from "./convertPriceUtils.js"; +export * from "./findPrice/findPriceByFeatureId.js"; +export * from "./formatPriceUtils.js"; diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx index deaf583ab..fea8b588b 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx @@ -7,23 +7,32 @@ import { getProductItemDisplay, type ProductItem, type ProductV2, - type SubscriptionUpdateV0Params, stripeToAtmnAmount, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; import { Check, Copy, PencilSimple } from "@phosphor-icons/react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { DateInputUnix } from "@/components/general/DateInputUnix"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; +import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; import { usePrepaidItems } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useEnv } from "@/utils/envUtils"; import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { pushPage } from "@/utils/genUtils"; +import { getStripeInvoiceLink } from "@/utils/linkUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; /** @@ -41,6 +50,7 @@ interface PrepaidEditorProps { prepaidItems: Array<{ feature_id?: string | null; feature?: { internal_id: string } | undefined; + billing_units?: number | null; }>; prepaidOptions: Record; onPrepaidChange: (featureId: string, quantity: number) => void; @@ -61,15 +71,18 @@ function PrepaidEditor({
{prepaidItems.map((item) => { const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; + const billingUnits = item.billing_units ?? 1; const inputId = `prepaid-${featureId}`; return (
- +
+ + + (billing_units: {billingUnits}) + +
; }; }; + invoiceItemsAction?: { + createInvoiceItems?: Array<{ + description?: string; + amount?: number; + customer?: string; + subscription?: string; + }>; + }; }; } @@ -626,47 +647,57 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {

🧾 Stripe Invoice Action

+ + {/* Immediate Line Items (addLineParams) */} {billingPlan.stripe.invoiceAction.addLineParams?.lines && billingPlan.stripe.invoiceAction.addLineParams.lines.length > 0 ? ( -
- {billingPlan.stripe.invoiceAction.addLineParams.lines.map( - ( - line: { - description?: string; - amount?: number; - }, - index: number, - ) => { - const amount = line.amount - ? stripeToAtmnAmount({ - amount: line.amount, - currency: "usd", - }) - : 0; - return ( -
- - {line.description || "Line item"} - - = 0 ? "text-green-400" : "text-red-400" - } +
+
+ Immediate charges: +
+
+ {billingPlan.stripe.invoiceAction.addLineParams.lines.map( + ( + line: { + description?: string; + amount?: number; + }, + index: number, + ) => { + const amount = line.amount + ? stripeToAtmnAmount({ + amount: line.amount, + currency: "usd", + }) + : 0; + return ( +
- ${amount.toFixed(2)} - -
- ); - }, - )} + + {line.description || "Line item"} + + = 0 ? "text-green-400" : "text-red-400" + } + > + ${amount.toFixed(2)} + +
+ ); + }, + )} +
) : ( -
No line items
+
+ No immediate line items +
)} +
View raw params @@ -678,10 +709,71 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
) : null} + {/* Stripe Invoice Items Action - Deferred charges added to next cycle */} + {billingPlan.stripe?.invoiceItemsAction?.createInvoiceItems && + billingPlan.stripe.invoiceItemsAction.createInvoiceItems.length > + 0 ? ( +
+

+ ⏱️ Stripe Invoice Items Action (Added to next cycle) +

+
+ {billingPlan.stripe.invoiceItemsAction.createInvoiceItems.map( + ( + item: { + description?: string; + amount?: number; + customer?: string; + subscription?: string; + }, + index: number, + ) => { + const amount = item.amount + ? stripeToAtmnAmount({ + amount: item.amount, + currency: "usd", + }) + : 0; + return ( +
+ + {item.description || "Invoice item"} + + = 0 ? "text-amber-400" : "text-red-400" + } + > + ${amount.toFixed(2)} + +
+ ); + }, + )} +
+
+ These items will appear on the customer's next invoice +
+
+ + View raw params + +
+									{JSON.stringify(
+										billingPlan.stripe.invoiceItemsAction,
+										null,
+										2,
+									)}
+								
+
+
+ ) : null} + {/* Empty Stripe section indicator */} {billingPlan.stripe && !billingPlan.stripe.subscriptionAction && - !billingPlan.stripe.invoiceAction ? ( + !billingPlan.stripe.invoiceAction && + !billingPlan.stripe.invoiceItemsAction ? (
No Stripe actions required
@@ -740,7 +832,7 @@ function useSubscriptionUpdatePreview({ body, enabled, }: { - body: SubscriptionUpdateV0Params | null; + body: UpdateSubscriptionV0Params | null; enabled: boolean; }) { const axiosInstance = useAxiosInstance(); @@ -780,17 +872,64 @@ function useSubscriptionUpdatePreview({ }; } -function useSubscriptionUpdate() { +interface SubscriptionUpdateParams { + body: UpdateSubscriptionV0Params; + useInvoice?: boolean; + enableProductImmediately?: boolean; +} + +function useSubscriptionUpdate({ + customerId, + onInvoiceCreated, +}: { + customerId?: string; + onInvoiceCreated?: (invoiceLink: string) => void; +}) { const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const { closeSheet } = useSheetStore(); return useMutation({ - mutationFn: async (body: SubscriptionUpdateV0Params) => { + mutationFn: async ({ + body, + useInvoice, + enableProductImmediately, + }: SubscriptionUpdateParams) => { + const requestBody = { + ...body, + invoice: useInvoice, + enable_product_immediately: useInvoice + ? enableProductImmediately + : undefined, + finalize_invoice: useInvoice ? false : undefined, + force_checkout: + useInvoice && enableProductImmediately === false ? true : undefined, + }; + const response = await axiosInstance.post( "/v1/subscriptions/update", - body, + requestBody, ); return response.data; }, + onSuccess: (data) => { + if (data?.invoice) { + onInvoiceCreated?.(data.invoice); + toast.success("Invoice created successfully"); + } else if (data?.checkout_url) { + toast.success("Redirecting to checkout..."); + window.open(data.checkout_url, "_blank"); + } else { + toast.success("Subscription updated successfully"); + } + closeSheet(); + if (customerId) { + queryClient.invalidateQueries({ queryKey: ["customer", customerId] }); + } + }, + onError: (error) => { + console.error("Update failed:", error); + }, }); } @@ -808,6 +947,10 @@ function SheetContent({ const customerId = customer?.id ?? customer?.internal_id; const entityId = cusProduct?.entity_id ?? undefined; + // Stripe + invoice handling + const { stripeAccount } = useOrgStripeQuery(); + const env = useEnv(); + const product = customizedProduct?.id ? customizedProduct : productV2; const { prepaidItems } = usePrepaidItems({ product }); @@ -874,23 +1017,30 @@ function SheetContent({ }; // Build the request body - const requestBody = useMemo(() => { + const requestBody = useMemo(() => { if (!customerId) return null; - const body: SubscriptionUpdateV0Params = { + const body: UpdateSubscriptionV0Params = { customer_id: customerId, product_id: product?.id, entity_id: entityId, customer_product_id: cusProduct.id ?? cusProduct.internal_product_id, }; - // Add options if there are prepaid items with quantities set + // Add options only if they have changed from initial values if (prepaidItems.length > 0) { const options = prepaidItems .map((item) => { const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; const quantity = prepaidOptions[featureId]; - if (quantity !== undefined && quantity !== null && featureId) { + const initialQuantity = initialPrepaidOptions[featureId]; + // Only include if changed from initial value + if ( + quantity !== undefined && + quantity !== null && + featureId && + quantity !== initialQuantity + ) { return { feature_id: featureId, quantity }; } return null; @@ -935,6 +1085,7 @@ function SheetContent({ cusProduct.internal_product_id, prepaidItems, prepaidOptions, + initialPrepaidOptions, customizedProduct?.items, customizedProduct?.free_trial, planCustomStartDate, @@ -948,12 +1099,35 @@ function SheetContent({ enabled: !!requestBody, }); - // Update mutation - const updateMutation = useSubscriptionUpdate(); + // Update mutation with invoice handling + const updateMutation = useSubscriptionUpdate({ + customerId, + onInvoiceCreated: (stripeInvoice) => { + const invoiceLink = getStripeInvoiceLink({ + stripeInvoice, + env, + accountId: stripeAccount?.id, + }); + window.open(invoiceLink, "_blank"); + }, + }); const handleConfirm = () => { if (!requestBody) return; - updateMutation.mutate(requestBody); + updateMutation.mutate({ body: requestBody, useInvoice: false }); + }; + + const handleInvoiceUpdate = ({ + enableProductImmediately, + }: { + enableProductImmediately: boolean; + }) => { + if (!requestBody) return; + updateMutation.mutate({ + body: requestBody, + useInvoice: true, + enableProductImmediately, + }); }; return ( @@ -1181,16 +1355,64 @@ function SheetContent({
{/* Footer Actions */} -
+
+ {/* Send an Invoice Button with Dropdown */} + + + + + +
+ + +
+
+
+ + {/* Confirm Update Button */} + + {/* Refresh Preview Button */}