diff --git a/CLAUDE.md b/CLAUDE.md index 652852da1..8a4348015 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ in the test logs. Use your common sense - This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. - Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better +- For single-line if statements (especially guard clauses), omit curly braces to keep code neat: `if (!isValid) throw error;` instead of wrapping in braces. + - Do not run "npx tsc" - run "tsc" instead as it is installed globally. - **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` diff --git a/server/src/external/stripe/invoices/utils/logStripeInvoice.ts b/server/src/external/stripe/invoices/utils/logStripeInvoice.ts new file mode 100644 index 000000000..7182f1880 --- /dev/null +++ b/server/src/external/stripe/invoices/utils/logStripeInvoice.ts @@ -0,0 +1,29 @@ +import type Stripe from "stripe"; +import type { Logger } from "@/external/logtail/logtailUtils"; + +/** + * Logs core information from a Stripe invoice for debugging. + */ +export const logStripeInvoice = ({ + logger, + stripeInvoice, + prefix, +}: { + logger: Logger; + stripeInvoice: Stripe.Invoice; + prefix?: string; +}) => { + const tag = prefix ? `[${prefix}]` : ""; + + logger.info(`${tag} Stripe Invoice`, { + id: stripeInvoice.id, + status: stripeInvoice.status, + total: stripeInvoice.total, + currency: stripeInvoice.currency, + hosted_invoice_url: stripeInvoice.hosted_invoice_url, + lines: stripeInvoice.lines.data.map((line) => ({ + description: line.description, + amount: line.amount, + })), + }); +}; diff --git a/server/src/external/stripe/invoices/utils/operations/getStripeInvoiceWithDiscounts.ts b/server/src/external/stripe/invoices/utils/operations/getStripeInvoiceWithDiscounts.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts index 12393156e..2268932ee 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts @@ -3,7 +3,7 @@ import type Stripe from "stripe"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import type { AttachParams } from "../../../../internal/customers/cusProducts/AttachParams"; import { MetadataService } from "../../../../internal/metadata/MetadataService"; -import { handleDeferredAutumnBillingPlan } from "./handleDeferredAutumnBillingPlan"; +import { executeDeferredBillingPlan } from "@/internal/billing/v2/execute/executeDeferredBillingPlan"; import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted"; import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid"; @@ -26,11 +26,8 @@ export const handleInvoicePaidMetadata = async ({ if (!metadata) return; // Handle deferred billing plan (v2 flow) - if ( - metadata.type === MetadataType.InvoiceCheckoutV2 || - metadata.type === MetadataType.InvoiceActionRequiredV2 - ) { - await handleDeferredAutumnBillingPlan({ ctx, metadata }); + if (metadata.type === MetadataType.DeferredInvoice) { + await executeDeferredBillingPlan({ ctx, metadata }); return; } diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts new file mode 100644 index 000000000..0c65ad613 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts @@ -0,0 +1,40 @@ +import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; +import { setupStripeInvoicePaidContext } from "./setupStripeInvoicePaidContext.js"; + +export const handleStripeInvoicePaid = async ({ + ctx, +}: { + ctx: StripeWebhookContext; +}) => { + const invoicePaidContext = await setupStripeInvoicePaidContext({ ctx }); + + if (!invoicePaidContext) { + ctx.logger.warn("[invoice.paid] invoicePaidContext not found, skipping"); + return; + } + + ctx.logger.debug( + `Received invoice.paid event for invoice ${invoicePaidContext.stripeInvoice.id}`, + ); + + // 1. Handle metadata-based payments (deferred billing, checkout, etc.) + // await handleInvoicePaidMetadata({ ctx, invoicePaidContext }); + + // 2. Handle discount/coupon rollover + // await handleInvoiceDiscounts({ ctx, invoicePaidContext }); + + // 3. Handle based on invoice type (subscription vs one-off) + if (invoicePaidContext.stripeSubscriptionId) { + // 3a. Convert to charge_automatically if needed + // await convertToChargeAutomatically({ ctx, invoicePaidContext }); + + // 3b. Create/update Autumn invoice + // await upsertAutumnInvoice({ ctx, invoicePaidContext }); + + // 3c. Trigger checkout rewards + // await triggerCheckoutRewards({ ctx, invoicePaidContext }); + } else { + // 3. Handle one-off invoice + // await handleOneOffInvoicePaid({ ctx, invoicePaidContext }); + } +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts new file mode 100644 index 000000000..1e77c59e9 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts @@ -0,0 +1,34 @@ +import type Stripe from "stripe"; +import { + getFullStripeInvoice, + invoiceToSubId, +} from "@/external/stripe/stripeInvoiceUtils.js"; +import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; + +export interface StripeInvoicePaidContext { + stripeInvoice: Stripe.Invoice; + stripeSubscriptionId: string | null; +} + +export const setupStripeInvoicePaidContext = async ({ + ctx, +}: { + ctx: StripeWebhookContext; +}): Promise => { + const { stripeEvent, stripeCli } = ctx; + + const invoiceData = stripeEvent.data.object as Stripe.Invoice; + + const stripeInvoice = await getFullStripeInvoice({ + stripeCli, + stripeId: invoiceData.id!, + expand: ["payments"], + }); + + const stripeSubscriptionId = invoiceToSubId({ invoice: stripeInvoice }) ?? null; + + return { + stripeInvoice, + stripeSubscriptionId, + }; +}; diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts similarity index 98% rename from server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts rename to server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts index f1967f5d1..c7b1c657f 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSchedulePhaseCompleted.ts @@ -14,7 +14,7 @@ import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { notNullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; export const handleSchedulePhaseCompleted = async ({ ctx, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubCanceled.ts similarity index 97% rename from server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts rename to server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubCanceled.ts index e3209b1f3..8813d81ea 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubCanceled.ts @@ -13,11 +13,11 @@ import { CusService } from "@/internal/customers/CusService.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { getLatestPeriodEnd, subToPeriodStartEnd, -} from "../../stripeSubUtils/convertSubUtils.js"; +} from "../../../stripeSubUtils/convertSubUtils.js"; export const isSubCanceled = ({ previousAttributes, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubPastDue.ts similarity index 95% rename from server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts rename to server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubPastDue.ts index c5a7a3bef..6922c361b 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubPastDue.ts @@ -5,7 +5,7 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; export const isSubPastDue = ({ previousAttributes, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubRenewed.ts similarity index 98% rename from server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts rename to server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubRenewed.ts index 9702b2aa5..0c230ed2b 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubRenewed.ts @@ -5,7 +5,7 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; const isSubRenewed = ({ previousAttributes, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubUpdated.ts similarity index 92% rename from server/src/external/stripe/webhookHandlers/handleSubUpdated.ts rename to server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubUpdated.ts index a87c2b7dc..141bbee13 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/legacy/handleSubUpdated/handleSubUpdated.ts @@ -15,11 +15,11 @@ import { RELEVANT_STATUSES, } from "@/internal/customers/cusProducts/CusProductService.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { handleSchedulePhaseCompleted } from "./handleSubUpdated/handleSchedulePhaseCompleted.js"; -import { handleSubCanceled } from "./handleSubUpdated/handleSubCanceled.js"; -import { handleSubPastDue } from "./handleSubUpdated/handleSubPastDue.js"; -import { handleSubRenewed } from "./handleSubUpdated/handleSubRenewed.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; +import { handleSchedulePhaseCompleted } from "./handleSchedulePhaseCompleted.js"; +import { handleSubCanceled } from "./handleSubCanceled.js"; +import { handleSubPastDue } from "./handleSubPastDue.js"; +import { handleSubRenewed } from "./handleSubRenewed.js"; export const handleSubscriptionUpdated = async ({ ctx, diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 1df5d1ed6..420bb1971 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -1,11 +1,7 @@ -import { - cusProductToLineItems, - type FullCusProduct, - type LineItem, - secondsToMs, -} from "@autumn/shared"; +import type { FullCusProduct, LineItem } from "@autumn/shared"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; +import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems"; import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems"; export const buildAutumnLineItems = ({ @@ -19,11 +15,7 @@ export const buildAutumnLineItems = ({ deletedCustomerProduct?: FullCusProduct; billingContext: BillingContext; }) => { - // billingCycleAnchor = billingCycleAnchor ?? now; - const { billingCycleAnchorMs, currentEpochMs, stripeSubscription } = - billingContext; - - const { org, logger } = ctx; + const { logger } = ctx; // For now, update subscription doesn't charge for existing usage. const arrearLineItems: LineItem[] = []; @@ -35,28 +27,22 @@ export const buildAutumnLineItems = ({ // }) // Get line items for ongoing cus product - const originalBillingCycleAnchorMs = stripeSubscription?.billing_cycle_anchor - ? secondsToMs(stripeSubscription.billing_cycle_anchor) - : "now"; const deletedLineItems = deletedCustomerProduct - ? cusProductToLineItems({ - cusProduct: deletedCustomerProduct, - nowMs: currentEpochMs, - billingCycleAnchorMs: originalBillingCycleAnchorMs, + ? customerProductToLineItems({ + ctx, + customerProduct: deletedCustomerProduct, + billingContext, direction: "refund", - org, - logger, + priceFilters: { excludeOneOffPrices: true }, }) : []; const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) => - cusProductToLineItems({ - cusProduct: newCustomerProduct, - nowMs: currentEpochMs, - billingCycleAnchorMs, + customerProductToLineItems({ + ctx, + customerProduct: newCustomerProduct, + billingContext, direction: "charge", - org, - logger, }), ); diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index 668c225ff..fc3774e84 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -1,16 +1,11 @@ -import { - cp, - cusProductToLineItems, - type FullCusProduct, - type LineItem, - secondsToMs, -} from "@autumn/shared"; +import { cp, type FullCusProduct, type LineItem } from "@autumn/shared"; import chalk from "chalk"; import type { Logger } from "@/external/logtail/logtailUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; +import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; const formatLineItem = (item: LineItem) => ({ description: item.description, @@ -130,25 +125,15 @@ export const buildSharedSubscriptionTrialLineItems = ({ if (siblingCustomerProducts.length === 0) return []; - const originalBillingCycleAnchorMs = stripeSubscription.billing_cycle_anchor - ? secondsToMs(stripeSubscription.billing_cycle_anchor) - : currentEpochMs; - - const billingCycleAnchorMs = - direction === "charge" - ? billingContext.billingCycleAnchorMs - : originalBillingCycleAnchorMs; - const lineItems: LineItem[] = []; for (const customerProduct of siblingCustomerProducts) { lineItems.push( - ...cusProductToLineItems({ - cusProduct: customerProduct, - nowMs: currentEpochMs, - billingCycleAnchorMs, + ...customerProductToLineItems({ + ctx, + customerProduct: customerProduct, + billingContext, direction, - org, - logger, + priceFilters: { excludeOneOffPrices: true }, }), ); } diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts index 0a8bd8d38..10928d352 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts @@ -1,4 +1,4 @@ -import type { LineItem } from "@autumn/shared"; +import { isOneOffPrice, type LineItem } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; @@ -42,8 +42,9 @@ export const filterLineItemsForTrialTransition = ({ } return lineItems.filter((lineItem) => { - const { billingTiming, direction } = lineItem.context; + const { billingTiming, direction, price } = lineItem.context; const isPositive = lineItem.amount > 0; + const isRecurringPrice = !isOneOffPrice(price); // Ending trial (isTrialing → !willBeTrialing): // Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges) @@ -55,7 +56,8 @@ export const filterLineItemsForTrialTransition = ({ // Starting trial (!isTrialing → willBeTrialing): // Filter out in_advance positive items (no charge for upcoming trial period) if (willBeTrialing) { - if (billingTiming === "in_advance" && isPositive) return false; + if (billingTiming === "in_advance" && isPositive && isRecurringPrice) + return false; } return true; diff --git a/server/src/internal/billing/v2/execute/executeAttachActions.ts b/server/src/internal/billing/v2/execute/executeAttachActions.ts deleted file mode 100644 index 8108f4b42..000000000 --- a/server/src/internal/billing/v2/execute/executeAttachActions.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { AttachContext, AttachPlan } from "../typesOld"; -import { applyCusProductActions } from "./executeAutumnActions/applyCusProductActions"; -import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction"; -import { executeStripeInvoiceAction } from "./executeStripeInvoiceAction"; -import { executeStripeSubAction } from "./executeStripeSubAction"; - -export const executeAttachActions = async ({ - ctx, - attachPlan, - attachContext, -}: { - ctx: AutumnContext; - attachPlan: AttachPlan; - attachContext: AttachContext; -}) => { - const { org, env, logger } = ctx; - const { - stripeSubAction, - stripeInvoiceAction, - ongoingCusProductAction, - scheduledCusProductAction, - - stripeCheckoutAction, - } = attachPlan; - - logger.info(`executing attach actions: `, { - checkoutInfo: { - shouldCreate: stripeCheckoutAction?.shouldCreate, - reason: stripeCheckoutAction?.reason, - }, - ongoingCusProductAction: { - action: ongoingCusProductAction?.action, - cusProduct: ongoingCusProductAction?.cusProduct.product.id, - }, - scheduledCusProductAction: scheduledCusProductAction - ? { - action: scheduledCusProductAction.action, - cusProduct: scheduledCusProductAction.cusProduct.product.id, - } - : undefined, - newFullCusProducts: attachPlan.newCusProducts.map((cp) => cp.product.id), - stripeSubAction, - stripeInvoiceAction: stripeInvoiceAction ?? "none", - }); - - // throw new RecaseError({ - // message: `test`, - // }); - - if (stripeCheckoutAction.shouldCreate) { - return await executeStripeCheckoutAction({ - ctx, - stripeCheckoutAction, - }); - } - - // 1. Create invoice if necessary - if (stripeInvoiceAction) { - await executeStripeInvoiceAction({ - ctx, - attachContext, - stripeCheckoutAction, - stripeInvoiceAction, - }); - } - - // 2. Execute stripe sub action - if (stripeSubAction) { - await executeStripeSubAction({ - ctx, - stripeSubAction, - }); - } - - // 3. Apply cus product actions - await applyCusProductActions({ - ctx, - ongoingCusProductAction, - scheduledCusProductAction, - newCusProducts: attachPlan.newCusProducts, - }); -}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/applyCusProductActions.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/applyCusProductActions.ts deleted file mode 100644 index 9b482a6bf..000000000 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/applyCusProductActions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { - FullCusProduct, - OngoingCusProductAction, - ScheduledCusProductAction, -} from "@autumn/shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction"; -import { insertNewCusProducts } from "./insertNewCusProducts"; - -export const applyCusProductActions = async ({ - ctx, - ongoingCusProductAction, - scheduledCusProductAction, - newCusProducts, -}: { - ctx: AutumnContext; - ongoingCusProductAction?: OngoingCusProductAction; - scheduledCusProductAction?: ScheduledCusProductAction; - newCusProducts: FullCusProduct[]; -}) => { - // 1. Insert new cus products - await insertNewCusProducts({ - ctx, - newCusProducts, - }); - - // 2. Apply ongoing cus product action - if (ongoingCusProductAction) { - await applyOngoingCusProductAction({ - ctx, - ongoingCusProductAction, - }); - } -}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/applyOngoingCusProductAction.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/applyOngoingCusProductAction.ts deleted file mode 100644 index f9c2d1109..000000000 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/applyOngoingCusProductAction.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { CusProductStatus, type OngoingCusProductAction } from "@autumn/shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { CusProductService } from "../../../../customers/cusProducts/CusProductService"; - -export const applyOngoingCusProductAction = async ({ - ctx, - ongoingCusProductAction, -}: { - ctx: AutumnContext; - ongoingCusProductAction: OngoingCusProductAction; -}) => { - const { action, cusProduct } = ongoingCusProductAction; - - if (action === "update") { - return; - } - - if (action === "expire") { - return await CusProductService.update({ - db: ctx.db, - cusProductId: cusProduct.id, - updates: { - status: CusProductStatus.Expired, - }, - }); - } - - if (action === "cancel") { - return await CusProductService.update({ - db: ctx.db, - cusProductId: cusProduct.id, - updates: { - canceled: true, - canceled_at: Date.now(), - // TODO: add ended_at - }, - }); - } - - if (action === "uncancel") { - return await CusProductService.update({ - db: ctx.db, - cusProductId: cusProduct.id, - updates: { - canceled: false, - canceled_at: null, - ended_at: null, - }, - }); - } -}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts deleted file mode 100644 index 816bc3224..000000000 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { - FeatureOptions, - FullCusProduct, - OngoingCusProductAction, -} from "@autumn/shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import type { QuantityUpdateDetails } from "../../typesOld"; -import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction"; -import { insertNewCusProducts } from "./insertNewCusProducts"; -import { updateCustomerEntitlements } from "./updateCustomerEntitlements"; -import { updateCustomerProductOptions } from "./updateCustomerProductOptions"; - -export const executeCusProductActions = async ({ - ctx, - ongoingCusProductAction, - newCusProducts, - quantityUpdateDetails, - updatedFeatureOptions, -}: { - ctx: AutumnContext; - ongoingCusProductAction?: OngoingCusProductAction; - newCusProducts: FullCusProduct[]; - quantityUpdateDetails?: QuantityUpdateDetails[]; - updatedFeatureOptions?: FeatureOptions[]; -}) => { - const { logger } = ctx; - - logger.info("Inserting new customer products"); - await insertNewCusProducts({ - ctx, - newCusProducts, - }); - - if (ongoingCusProductAction) { - logger.info( - `Applying ongoing customer product action: ${ongoingCusProductAction.action}`, - ); - await applyOngoingCusProductAction({ - ctx, - ongoingCusProductAction, - }); - } - - if (updatedFeatureOptions && ongoingCusProductAction?.cusProduct) { - logger.info("Updating customer product options"); - await updateCustomerProductOptions({ - ctx, - customerProductId: ongoingCusProductAction.cusProduct.id, - updatedFeatureOptions, - }); - } - - if (quantityUpdateDetails && quantityUpdateDetails.length > 0) { - logger.info("Updating customer entitlements"); - await updateCustomerEntitlements({ - ctx, - quantityUpdateDetails, - }); - } - - logger.info("Successfully executed all customer product actions"); -}; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index c601cd255..61e7f682e 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -3,6 +3,7 @@ import type { BillingContext } from "@/internal/billing/v2/billingContext"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import type { BillingResult } from "@/internal/billing/v2/types/billingResult"; export const executeBillingPlan = async ({ ctx, @@ -12,19 +13,22 @@ export const executeBillingPlan = async ({ ctx: AutumnContext; billingContext: BillingContext; billingPlan: BillingPlan; -}) => { - const result = await executeStripeBillingPlan({ +}): Promise => { + const stripeBillingResult = await executeStripeBillingPlan({ ctx, billingPlan, billingContext, }); - if (result.deferred) return result; + if (stripeBillingResult.deferred) + return { + stripe: stripeBillingResult, + }; await executeAutumnBillingPlan({ ctx, autumnBillingPlan: billingPlan.autumn, }); - return { billingPlan }; + return { stripe: stripeBillingResult }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts similarity index 88% rename from server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts rename to server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts index 80a4b0a5e..92214bad7 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts @@ -5,7 +5,7 @@ import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan"; import { MetadataService } from "@/internal/metadata/MetadataService"; -export const handleDeferredAutumnBillingPlan = async ({ +export const executeDeferredBillingPlan = async ({ ctx, metadata, }: { @@ -20,14 +20,14 @@ export const handleDeferredAutumnBillingPlan = async ({ return; } - const { billingPlan, billingContext } = data; + const { billingPlan, billingContext, resumeAfter } = data; // Execute stripe billing plan await executeStripeBillingPlan({ ctx, billingPlan, billingContext, - resumeFromDeferred: true, + resumeAfter, }); await executeAutumnBillingPlan({ diff --git a/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts b/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts deleted file mode 100644 index 0ebf63146..000000000 --- a/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createStripeCli } from "@/external/connect/createStripeCli"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { StripeSubAction } from "../../typesOld"; - -/** - * Execute Stripe subscription item updates. - * Handles creating, updating, and deleting subscription items. - */ -export const executeStripeSubscriptionUpdate = async ({ - ctx, - stripeSubscriptionAction, -}: { - ctx: AutumnContext; - stripeSubscriptionAction: StripeSubAction; -}) => { - const { org, env, logger } = ctx; - const stripeClient = createStripeCli({ org, env }); - if ( - !stripeSubscriptionAction.items || - stripeSubscriptionAction.items.length === 0 - ) { - logger.info("No subscription items to update"); - return; - } - - logger.info( - `Updating ${stripeSubscriptionAction.items.length} subscription items`, - ); - - for (const subscriptionItem of stripeSubscriptionAction.items) { - if (subscriptionItem.deleted) { - logger.info(`Deleting subscription item ${subscriptionItem.id}`); - await stripeClient.subscriptionItems.del(subscriptionItem.id!); - } else if (subscriptionItem.id) { - logger.info( - `Updating subscription item ${subscriptionItem.id} to quantity ${subscriptionItem.quantity}`, - ); - await stripeClient.subscriptionItems.update(subscriptionItem.id, { - quantity: subscriptionItem.quantity, - proration_behavior: "none", - }); - } else { - logger.info( - `Creating new subscription item for price ${subscriptionItem.price} with quantity ${subscriptionItem.quantity}`, - ); - await stripeClient.subscriptionItems.create({ - subscription: stripeSubscriptionAction.subId!, - price: subscriptionItem.price!, - quantity: subscriptionItem.quantity, - proration_behavior: "none", - }); - } - } - - logger.info("Successfully updated all subscription items"); -}; 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 3940658a2..3265716ef 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts @@ -4,7 +4,9 @@ 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 { billingPlanToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs"; import type { + AutumnBillingPlan, StripeSubscriptionAction, StripeSubscriptionScheduleAction, } from "@/internal/billing/v2/types/billingPlan"; @@ -12,11 +14,13 @@ import type { export const buildStripeSubscriptionAction = ({ ctx, billingContext, + autumnBillingPlan, finalCustomerProducts, stripeSubscriptionScheduleAction, }: { ctx: AutumnContext; billingContext: BillingContext; + autumnBillingPlan: AutumnBillingPlan; finalCustomerProducts: FullCusProduct[]; stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction; }): StripeSubscriptionAction | undefined => { @@ -28,6 +32,11 @@ export const buildStripeSubscriptionAction = ({ finalCustomerProducts, }); + const oneOffItemSpecs = billingPlanToOneOffStripeItemSpecs({ + ctx, + autumnBillingPlan, + }); + // Case 1: No subscription and sub items update is empty -> no action if (!stripeSubscription && subItemsUpdate.length === 0) { return undefined; @@ -39,7 +48,10 @@ export const buildStripeSubscriptionAction = ({ ctx, billingContext, subItemsUpdate, - addInvoiceItems: [], + addInvoiceItems: oneOffItemSpecs.map((item) => ({ + price: item.stripePriceId, + quantity: item.quantity, + })), }); } diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index 3d73f0a2f..930724342 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -37,6 +37,7 @@ export const evaluateStripeBillingPlan = async ({ const stripeSubscriptionAction = buildStripeSubscriptionAction({ ctx, billingContext, + autumnBillingPlan, finalCustomerProducts: finalFullCustomer.customer_products, }); 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 9464b65e8..2d7365ef7 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -1,4 +1,3 @@ -import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; @@ -6,19 +5,20 @@ import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stri import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction"; import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; +import { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; -import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult"; export const executeStripeBillingPlan = async ({ ctx, billingPlan, billingContext, - resumeFromDeferred = false, + resumeAfter, }: { ctx: AutumnContext; billingPlan: BillingPlan; billingContext: BillingContext; - resumeFromDeferred?: boolean; + resumeAfter?: StripeBillingStage; }): Promise => { const { logger } = ctx; const { @@ -28,39 +28,46 @@ export const executeStripeBillingPlan = async ({ subscriptionScheduleAction: stripeSubscriptionScheduleAction, } = billingPlan.stripe; - if (stripeInvoiceAction && !resumeFromDeferred) { - const result = await executeStripeInvoiceAction({ + // Collect results from each stage + let invoiceResult: StripeBillingPlanResult | undefined; + let subscriptionResult: StripeBillingPlanResult | undefined; + let stripeSubscription = billingContext.stripeSubscription; + + const resumeAfterInvoiceAction = + resumeAfter === StripeBillingStage.InvoiceAction; + + const resumeAfterSubscriptionAction = + resumeAfter === StripeBillingStage.SubscriptionAction; + + if (stripeInvoiceAction && resumeAfterInvoiceAction) { + invoiceResult = await executeStripeInvoiceAction({ ctx, billingPlan, billingContext, }); - if (result.deferred) return result; + if (invoiceResult.deferred) return invoiceResult; } - if (stripeInvoiceItemsAction?.createInvoiceItems) { - logger.info( - "[executeStripeBillingPlan] Creating invoice items for next cycle", - ); + if ( + stripeInvoiceItemsAction?.createInvoiceItems && + !resumeAfterSubscriptionAction + ) { + logger.info("[execStripePlan] Creating invoice items for next cycle"); await createStripeInvoiceItems({ ctx, invoiceItems: stripeInvoiceItemsAction.createInvoiceItems, }); } - let stripeSubscription: Stripe.Subscription | undefined = - billingContext.stripeSubscription; - - if (stripeSubscriptionAction) { - const result = await executeStripeSubscriptionAction({ + if (stripeSubscriptionAction && !resumeAfterSubscriptionAction) { + subscriptionResult = await executeStripeSubscriptionAction({ ctx, billingPlan, billingContext, }); - - if (result?.deferred) return result; - - stripeSubscription = result.stripeSubscription; + if (subscriptionResult?.deferred) return subscriptionResult; + stripeSubscription = subscriptionResult.stripeSubscription; } if (stripeSubscriptionScheduleAction) { @@ -80,5 +87,12 @@ export const executeStripeBillingPlan = async ({ } } - return { stripeInvoice: undefined }; + return { + stripeSubscription: subscriptionResult?.stripeSubscription, + + stripeInvoice: + subscriptionResult?.stripeInvoice ?? invoiceResult?.stripeInvoice, + + actionRequired: invoiceResult?.actionRequired, + }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts index b07d49e65..28ca9625d 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -1,11 +1,14 @@ +import { ms } from "@autumn/shared"; 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 { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan"; import type { BillingPlan, StripeInvoiceMetadata, } from "@/internal/billing/v2/types/billingPlan"; -import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult"; +import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode"; import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; @@ -30,30 +33,36 @@ export const executeStripeInvoiceAction = async ({ logger.debug("[executeStripeInvoiceAction] Creating invoice for billing"); - const { invoice } = await createInvoiceForBilling({ + const { invoice, actionRequired } = 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; + + const deferredInvoiceMode = isDeferredInvoiceMode({ + billingContext, + }); + + // const actionRequired = invoice.status === "open" && !isInvoiceMode; + + const deferBillingPlan = + invoice.status === "open" && (deferredInvoiceMode || actionRequired); + if (deferBillingPlan) { - logger.debug( - `Deferring billing plan, enableProductAfterInvoice: ${enableProductAfterInvoice}, invoiceActionRequired: ${invoiceActionRequired}`, - ); + logger.debug(`Deferring billing plan`); + await insertMetadataFromBillingPlan({ ctx, billingPlan, billingContext, - enableProductAfterInvoice, - invoiceActionRequired, stripeInvoice: invoice, + expiresAt: deferredInvoiceMode + ? Date.now() + ms.days(10) + : Date.now() + ms.days(30), + resumeAfter: StripeBillingStage.InvoiceAction, }); await upsertInvoiceFromBilling({ @@ -66,6 +75,7 @@ export const executeStripeInvoiceAction = async ({ return { stripeInvoice: invoice, deferred: true, + actionRequired, }; } 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 ece122bb4..94dfbfc4b 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -1,85 +1,22 @@ -import { InternalError } from "@autumn/shared"; -import type Stripe from "stripe"; +import { ms } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli"; +import { logStripeInvoice } from "@/external/stripe/invoices/utils/logStripeInvoice"; import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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 { finalizeStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; +import { executeStripeSubscriptionOperation } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation"; +import { getLatestInvoiceFromSubscriptionAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction"; +import { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan"; +import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/billingResult"; import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; -type InvoiceModeParams = { - collection_method?: "send_invoice"; - days_until_due?: number; -}; - -const executeSubscriptionOperation = async ({ - ctx, - billingContext, - subscriptionAction, - invoiceModeParams, -}: { - ctx: AutumnContext; - billingContext: BillingContext; - subscriptionAction: StripeSubscriptionAction; - invoiceModeParams: InvoiceModeParams; -}) => { - const { org, env } = ctx; - const stripeClient = createStripeCli({ org, env }); - - switch (subscriptionAction.type) { - case "update": { - let stripeSubscription = billingContext.stripeSubscription; - if ( - stripeSubscription && - stripeSubscription.billing_mode.type !== "flexible" - ) { - stripeSubscription = await stripeClient.subscriptions.migrate( - stripeSubscription?.id, - { - billing_mode: { type: "flexible" }, - }, - ); - } - - return await stripeClient.subscriptions.update( - subscriptionAction.stripeSubscriptionId, - { - ...subscriptionAction.params, - ...invoiceModeParams, - expand: ["latest_invoice"], - }, - ); - } - case "create": - 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, @@ -95,6 +32,7 @@ export const executeStripeSubscriptionAction = async ({ if (!subscriptionAction) return {}; let { invoiceMode, stripeSubscription, currentEpochMs } = billingContext; + const { logger } = ctx; // 2. Lock stripe subscription if (stripeSubscription) { @@ -104,43 +42,36 @@ export const executeStripeSubscriptionAction = async ({ }); } - // Invoice mode: - const invoiceModeParams = invoiceMode - ? { - collection_method: "send_invoice" as const, - days_until_due: 30, - } - : {}; - - ctx.logger.debug( - `[executeStripeSubscriptionAction] Executing subscription operation: ${subscriptionAction.type}`, - ); - stripeSubscription = await executeSubscriptionOperation({ + logger.debug(`[execSubAction] Executing subscription operation`); + stripeSubscription = await executeStripeSubscriptionOperation({ ctx, billingContext, subscriptionAction, - invoiceModeParams, }); - const latestStripeInvoice = - subscriptionAction.type === "create" - ? (stripeSubscription.latest_invoice as Stripe.Invoice) - : undefined; + let latestStripeInvoice = getLatestInvoiceFromSubscriptionAction({ + stripeSubscription, + subscriptionAction, + billingContext, + }); - // Defer billing plan - const enableProductAfterInvoice = - invoiceMode?.enableProductImmediately === false; + if (latestStripeInvoice && invoiceMode?.finalizeInvoice) { + logger.debug(`[execSubAction] Finalizing invoice`); + latestStripeInvoice = await finalizeStripeInvoice({ + stripeCli: createStripeCli({ org: ctx.org, env: ctx.env }), + invoiceId: latestStripeInvoice.id, + }); - const invoiceActionRequired = - subscriptionAction.type === "create" && - latestStripeInvoice?.status === "open"; + logStripeInvoice({ + logger, + stripeInvoice: latestStripeInvoice, + }); + } - const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired; + const deferBillingPlan = latestStripeInvoice?.status === "open"; if (latestStripeInvoice) { - ctx.logger.debug( - `[executeStripeSubscriptionAction] Upserting invoice from billing: ${latestStripeInvoice.id}`, - ); + logger.debug(`[execSubAction] Upserting invoice from billing`); await upsertInvoiceFromBilling({ ctx, stripeInvoice: latestStripeInvoice, @@ -150,16 +81,27 @@ export const executeStripeSubscriptionAction = async ({ } if (deferBillingPlan) { - ctx.logger.debug( - `[executeStripeSubscriptionAction] Inserting metadata from billing plan`, - ); + if (!latestStripeInvoice) { + logger.error( + "Attempted to defer billing plan with no latest stripe invoice", + ); + } + + logger.debug(`[execSubAction] Inserting metadata from billing plan`); + + // Required if we resume after and carry out subscription schedule action + const deferredBillingContext = { + ...billingContext, + stripeSubscription, + }; + await insertMetadataFromBillingPlan({ ctx, billingPlan, - billingContext, - enableProductAfterInvoice, - invoiceActionRequired, + billingContext: deferredBillingContext, stripeInvoice: latestStripeInvoice, + expiresAt: Date.now() + ms.days(30), + resumeAfter: StripeBillingStage.SubscriptionAction, }); return { @@ -175,9 +117,7 @@ export const executeStripeSubscriptionAction = async ({ }); // Add subscription to DB - ctx.logger.debug( - `[executeStripeSubscriptionAction] Upserting subscription from billing: ${stripeSubscription.id}`, - ); + logger.debug(`[execSubAction] Upserting subscription from billing`); await upsertSubscriptionFromBilling({ ctx, stripeSubscription, @@ -185,9 +125,7 @@ export const executeStripeSubscriptionAction = async ({ // If the stripe subscription is canceled, remove the subscription from the billing plan if (isStripeSubscriptionCanceled(stripeSubscription)) { - ctx.logger.debug( - `[executeStripeSubscriptionAction] Subscription canceled, removing subscription from billing plan: ${stripeSubscription.id}`, - ); + logger.debug(`[execSubAction] removing subscription from billing plan`); removeStripeSubscriptionIdFromBillingPlan({ autumnBillingPlan: billingPlan.autumn, stripeSubscriptionId: stripeSubscription.id, 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 9390bc31e..bd0bc7083 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 @@ -33,11 +33,12 @@ export const createInvoiceForBilling = async ({ const shouldFinalizeInvoice = invoiceMode ? invoiceMode.finalizeInvoice : true; - const shouldPayImmediately = invoiceMode - ? invoiceMode.enableProductImmediately - : true; - const collectionMethod = shouldPayImmediately ? "charge_automatically" : "send_invoice"; + const isInvoiceMode = Boolean(invoiceMode); + + const collectionMethod = isInvoiceMode + ? "send_invoice" + : "charge_automatically"; const draftInvoice = await createStripeInvoice({ stripeCli, @@ -46,26 +47,26 @@ export const createInvoiceForBilling = async ({ collectionMethod, }); - await addStripeInvoiceLines({ + const invoiceWithLines = await addStripeInvoiceLines({ stripeCli, invoiceId: draftInvoice.id, lines: addLineParams.lines, }); if (!shouldFinalizeInvoice) { - return { paid: false, invoice: draftInvoice }; + return { paid: false, invoice: invoiceWithLines }; } const finalizedInvoice = await finalizeStripeInvoice({ stripeCli, - invoiceId: draftInvoice.id, + invoiceId: invoiceWithLines.id, }); if (finalizedInvoice.status === "paid") { return { paid: true, invoice: finalizedInvoice }; } - if (!shouldPayImmediately) { + if (isInvoiceMode) { return { paid: false, invoice: finalizedInvoice }; } @@ -73,6 +74,5 @@ export const createInvoiceForBilling = async ({ stripeCli, invoiceId: finalizedInvoice.id, paymentMethod: billingContext.paymentMethod, - onFailure: "return_url", }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/handleInvoicePaymentFailure.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/handleInvoicePaymentFailure.ts new file mode 100644 index 000000000..76d7a4745 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/handleInvoicePaymentFailure.ts @@ -0,0 +1,69 @@ +import type Stripe from "stripe"; +import type { PayInvoiceResult } from "./payStripeInvoice"; + +// ============================================ +// Helpers +// ============================================ + +/** + * Maps Stripe error codes to payment failure codes. + * Returns "3ds_required" for authentication errors, "payment_failed" for all others. + */ +const getFailureCodeFromStripeError = ({ + stripeError, +}: { + stripeError: Stripe.errors.StripeError; +}): "3ds_required" | "payment_failed" => { + const authCodes = ["authentication_required", "authentication_not_handled"]; + + if (authCodes.includes(stripeError.code ?? "")) { + return "3ds_required"; + } + + return "payment_failed"; +}; + +// ============================================ +// Handle Payment Failure +// ============================================ + +/** + * Builds a failed PayInvoiceResult from an invoice and error. + * Handles "no_payment_method" and Stripe errors - throws non-Stripe errors. + */ +export const handleInvoicePaymentFailure = ({ + invoice, + error, +}: { + invoice: Stripe.Invoice; + error: Error | "no_payment_method"; +}): PayInvoiceResult => { + // 1. No payment method case + if (error === "no_payment_method") { + return { + paid: false, + invoice, + actionRequired: { + code: "payment_method_required", + reason: "No payment method found", + }, + }; + } + + // 2. Check if it's a Stripe error + const stripeError = error as Stripe.errors.StripeError; + const isStripeError = stripeError.type !== undefined; + + if (!isStripeError) throw error; + + // 3. Handle Stripe errors + return { + paid: false, + invoice, + actionRequired: { + code: getFailureCodeFromStripeError({ stripeError }), + reason: stripeError.message ?? "Failed to pay invoice", + }, + stripeError, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts index 058feb668..20c7788ee 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts @@ -1,30 +1,25 @@ -import { ErrCode } from "@autumn/shared"; +import { type PaymentFailureCode, tryCatch } from "@autumn/shared"; import type Stripe from "stripe"; -import RecaseError from "@/utils/errorUtils.js"; +import { handleInvoicePaymentFailure } from "./handleInvoicePaymentFailure"; // ============================================ // Types // ============================================ -export type PaymentFailureMode = - | "return_url" - | "checkout_session" - | "throw" - | "void"; - export type PayInvoiceResult = { paid: boolean; invoice: Stripe.Invoice; - hostedUrl?: string; - error?: Error; - createCheckoutSession?: boolean; + actionRequired?: { + code: PaymentFailureCode; + reason: string; + }; + stripeError?: Stripe.errors.StripeError; }; export type PayStripeInvoiceParams = { stripeCli: Stripe; invoiceId: string; paymentMethod?: Stripe.PaymentMethod | null; - onFailure?: PaymentFailureMode; }; // ============================================ @@ -35,10 +30,9 @@ export const payStripeInvoice = async ({ stripeCli, invoiceId, paymentMethod, - onFailure = "return_url", }: PayStripeInvoiceParams): Promise => { // 1. Retrieve invoice to check status - let invoice = await stripeCli.invoices.retrieve(invoiceId); + const invoice = await stripeCli.invoices.retrieve(invoiceId); // 2. Already paid - return success if (invoice.status === "paid") { @@ -48,93 +42,27 @@ export const payStripeInvoice = async ({ }; } - // 3. No payment method - handle based on failure mode + // 3. No payment method - return failure if (!paymentMethod) { - return handlePaymentFailure({ - stripeCli, + return handleInvoicePaymentFailure({ invoice, - onFailure, - error: new RecaseError({ - message: "No payment method found", - code: ErrCode.CustomerHasNoPaymentMethod, - statusCode: 400, - }), + error: "no_payment_method", }); } // 4. Attempt payment - try { - invoice = await stripeCli.invoices.pay(invoiceId, { + const { data: paidInvoice, error } = await tryCatch( + stripeCli.invoices.pay(invoiceId, { payment_method: paymentMethod.id, - }); + }), + ); - return { - paid: true, - invoice, - }; - } catch (err) { - const errMessage = - err instanceof Error ? err.message : "Failed to pay invoice"; - - return handlePaymentFailure({ - stripeCli, - invoice, - onFailure, - error: new RecaseError({ - message: errMessage, - code: ErrCode.PayInvoiceFailed, - statusCode: 400, - }), - }); - } -}; - -// ============================================ -// Handle Payment Failure -// ============================================ - -const handlePaymentFailure = async ({ - stripeCli, - invoice, - onFailure, - error, -}: { - stripeCli: Stripe; - invoice: Stripe.Invoice; - onFailure: PaymentFailureMode; - error: Error; -}): Promise => { - switch (onFailure) { - case "throw": - throw error; - - case "checkout_session": - return { - paid: false, - invoice, - hostedUrl: undefined, - error, - createCheckoutSession: true, - }; - - case "void": - try { - await stripeCli.invoices.voidInvoice(invoice.id!); - } catch (_voidError) { - // Silently fail void attempt - } - return { - paid: false, - invoice, - error, - }; - - default: - return { - paid: false, - invoice, - hostedUrl: invoice.hosted_invoice_url || undefined, - error, - }; + if (error) { + return handleInvoicePaymentFailure({ invoice, error }); } + + return { + paid: true, + invoice: paidInvoice, + }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts index c827f6284..55c2830ae 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts @@ -1,5 +1,5 @@ -import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { willStripeSubscriptionUpdateCreateInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice"; import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan"; export const shouldCreateManualStripeInvoice = ({ @@ -12,15 +12,15 @@ export const shouldCreateManualStripeInvoice = ({ const isCreateAction = stripeSubscriptionAction?.type === "create"; if (isCreateAction) return false; - const { stripeSubscription, trialContext } = billingContext; + const { stripeSubscription } = billingContext; if (!stripeSubscription) return false; - const isTrialing = isStripeSubscriptionTrialing(stripeSubscription); - const endingTrial = trialContext?.trialEndsAt === null; - const updateWillCharge = - stripeSubscriptionAction?.type === "update" && isTrialing && endingTrial; + const updateWillCreateInvoice = willStripeSubscriptionUpdateCreateInvoice({ + billingContext, + stripeSubscriptionAction, + }); - if (updateWillCharge) return false; + if (updateWillCreateInvoice) return false; return true; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs.ts new file mode 100644 index 000000000..72a1563f0 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs.ts @@ -0,0 +1,23 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; + +export const billingPlanToOneOffStripeItemSpecs = ({ + ctx, + autumnBillingPlan, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const newCustomerProducts = autumnBillingPlan.insertCustomerProducts; + + const oneOffItems = newCustomerProducts.flatMap((customerProduct) => { + const { oneOffItems } = customerProductToStripeItemSpecs({ + ctx, + customerProduct, + }); + return oneOffItems; + }); + + return oneOffItems; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts index b7c6c839e..01b20db15 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -37,10 +37,6 @@ export const buildStripeSubscriptionUpdateAction = ({ trialEndsAt && msToSeconds(trialEndsAt) !== stripeSubscription?.trial_end; - console.log("shouldSetTrialEnd", shouldSetTrialEnd); - console.log("trialEndsAt", trialEndsAt); - console.log("stripeSubscription?.trial_end", stripeSubscription?.trial_end); - const shouldUnsetTrialEnd = !scheduleManagesSubscription && trialEndsAt === null; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts new file mode 100644 index 000000000..9972e0059 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -0,0 +1,74 @@ +import { InternalError } from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan"; + +type InvoiceModeParams = { + collection_method?: "send_invoice"; + days_until_due?: number; +}; + +export const executeStripeSubscriptionOperation = async ({ + ctx, + billingContext, + subscriptionAction, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + subscriptionAction: StripeSubscriptionAction; +}) => { + const { org, env } = ctx; + const stripeClient = createStripeCli({ org, env }); + + const invoiceModeParams = billingContext.invoiceMode + ? { + collection_method: "send_invoice" as const, + days_until_due: 30, + } + : {}; + + switch (subscriptionAction.type) { + case "update": { + let stripeSubscription = billingContext.stripeSubscription; + if ( + stripeSubscription && + stripeSubscription.billing_mode.type !== "flexible" + ) { + stripeSubscription = await stripeClient.subscriptions.migrate( + stripeSubscription?.id, + { + billing_mode: { type: "flexible" }, + }, + ); + } + + return await stripeClient.subscriptions.update( + subscriptionAction.stripeSubscriptionId, + { + ...subscriptionAction.params, + ...invoiceModeParams, + expand: ["latest_invoice"], + }, + ); + } + case "create": + 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", + }); + } +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction.ts new file mode 100644 index 000000000..833084d37 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction.ts @@ -0,0 +1,30 @@ +import type Stripe from "stripe"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { willStripeSubscriptionUpdateCreateInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice"; +import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan"; + +/** + * Returns the latest invoice from a subscription action if one was created. + * Only create actions and updates that trigger proration generate an invoice. + */ +export const getLatestInvoiceFromSubscriptionAction = ({ + stripeSubscription, + subscriptionAction, + billingContext, +}: { + stripeSubscription: Stripe.Subscription; + subscriptionAction: StripeSubscriptionAction; + billingContext: BillingContext; +}): Stripe.Invoice | undefined => { + const isCreateAction = subscriptionAction.type === "create"; + const updateWillCreateInvoice = willStripeSubscriptionUpdateCreateInvoice({ + billingContext, + stripeSubscriptionAction: subscriptionAction, + }); + + if (isCreateAction || updateWillCreateInvoice) { + return stripeSubscription.latest_invoice as Stripe.Invoice; + } + + return undefined; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice.ts new file mode 100644 index 000000000..d37d153d2 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/willStripeSubscriptionUpdateCreateInvoice.ts @@ -0,0 +1,22 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan"; +import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; + +export const willStripeSubscriptionUpdateCreateInvoice = ({ + billingContext, + stripeSubscriptionAction, +}: { + billingContext: BillingContext; + stripeSubscriptionAction?: StripeSubscriptionAction; +}): boolean => { + const actionType = stripeSubscriptionAction?.type; + if (actionType !== "update") return false; + + const { isTrialing, willBeTrialing } = getTrialStateTransition({ + billingContext, + }); + + if (isTrialing && !willBeTrialing) return true; + + return false; +}; diff --git a/server/src/internal/billing/v2/types/autumnBillingPlan.ts b/server/src/internal/billing/v2/types/autumnBillingPlan.ts index e64ef8249..9dbf53c2e 100644 --- a/server/src/internal/billing/v2/types/autumnBillingPlan.ts +++ b/server/src/internal/billing/v2/types/autumnBillingPlan.ts @@ -34,9 +34,15 @@ export const AutumnBillingPlanSchema = z.object({ export type AutumnBillingPlan = z.infer; +export enum StripeBillingStage { + InvoiceAction = "invoice_action", + SubscriptionAction = "subscription_action", +} + export type DeferredAutumnBillingPlanData = { orgId: string; env: AppEnv; billingPlan: BillingPlan; billingContext: BillingContext; + resumeAfter: StripeBillingStage; }; diff --git a/server/src/internal/billing/v2/types/billingPlan.ts b/server/src/internal/billing/v2/types/billingPlan.ts index aa32f8254..4c0a8a7f6 100644 --- a/server/src/internal/billing/v2/types/billingPlan.ts +++ b/server/src/internal/billing/v2/types/billingPlan.ts @@ -3,8 +3,6 @@ import { type AutumnBillingPlan, AutumnBillingPlanSchema, type DeferredAutumnBillingPlanData, - type InvoiceMode, - InvoiceModeSchema, } from "./autumnBillingPlan"; import { type StripeBillingPlan, @@ -22,7 +20,6 @@ import { export { AutumnBillingPlanSchema, - InvoiceModeSchema, StripeBillingPlanSchema, StripeInvoiceActionSchema, StripeInvoiceItemsActionSchema, @@ -30,7 +27,6 @@ export { StripeSubscriptionScheduleActionSchema, type AutumnBillingPlan, type DeferredAutumnBillingPlanData, - type InvoiceMode, type StripeBillingPlan, type StripeInvoiceAction, type StripeInvoiceItemsAction, diff --git a/server/src/internal/billing/v2/types/billingResult.ts b/server/src/internal/billing/v2/types/billingResult.ts new file mode 100644 index 000000000..abac522d8 --- /dev/null +++ b/server/src/internal/billing/v2/types/billingResult.ts @@ -0,0 +1,16 @@ +import type { PaymentFailureCode } from "@autumn/shared"; +import type Stripe from "stripe"; + +export interface StripeBillingPlanResult { + deferred?: boolean; + stripeInvoice?: Stripe.Invoice; + stripeSubscription?: Stripe.Subscription; + actionRequired?: { + code: PaymentFailureCode; + reason: string; + }; +} + +export interface BillingResult { + stripe: StripeBillingPlanResult; +} diff --git a/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts b/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts deleted file mode 100644 index c3d40f191..000000000 --- a/server/src/internal/billing/v2/types/stripeBillingPlanResult.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type Stripe from "stripe"; - -export interface StripeBillingPlanResult { - deferred?: boolean; - stripeInvoice?: Stripe.Invoice; - stripeSubscription?: Stripe.Subscription; -} diff --git a/server/src/internal/billing/v2/updateSubscription/compute/computeOneOffLineItems.ts b/server/src/internal/billing/v2/updateSubscription/compute/computeOneOffLineItems.ts new file mode 100644 index 000000000..bb580fc8f --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/compute/computeOneOffLineItems.ts @@ -0,0 +1,34 @@ +import { cp, isCustomerProductFree } from "@autumn/shared"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; + +export const computeOneOffLineItems = ({ + billingContext, + autumnBillingPlan, +}: { + billingContext: UpdateSubscriptionBillingContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const { insertCustomerProducts } = autumnBillingPlan; + const newCustomerProduct = insertCustomerProducts?.[0]; + if (!newCustomerProduct) return []; + + // Only allow one off items if going from free -> paid + const currentIsFree = isCustomerProductFree(billingContext.customerProduct); + const { valid: newIsPaidRecurring } = cp(newCustomerProduct) + .paid() + .recurring(); + + const includeOneOffItems = currentIsFree && newIsPaidRecurring; + + if (!includeOneOffItems) return []; + + // const newOneOffItems = cusProductToLineItems({ + // cusProduct: newCustomerProduct, + // nowMs: billingContext.currentEpochMs, + // billingCycleAnchorMs: billingContext.billingCycleAnchorMs, + // direction: "charge", + // org: billingContext.org, + // logger: billingContext.logger, + // }); +}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts index fa674faea..c342aae96 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts @@ -5,6 +5,8 @@ import { findFeatureByInternalId, findFeatureOptionsByFeature, InternalError, + isOneOffPrice, + RecaseError, } from "@autumn/shared"; import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -49,12 +51,6 @@ export const computeUpdateQuantityDetails = ({ }); } - if (!billingCycleAnchorMs) { - throw new InternalError({ - message: `[Quantity Update] billingCycleAnchorMs is required (no active subscription)`, - }); - } - const feature = findFeatureByInternalId({ features, internalId: internalFeatureId, @@ -77,6 +73,12 @@ export const computeUpdateQuantityDetails = ({ errorOnNotFound: true, }); + if (isOneOffPrice(customerPrice.price)) { + throw new RecaseError({ + message: `Not allowed to update feature quantity for one off items.`, + }); + } + const customerEntitlement = customerPriceToCustomerEntitlement({ customerPrice, customerEntitlements: customerProduct.customer_entitlements, @@ -91,6 +93,12 @@ export const computeUpdateQuantityDetails = ({ customerEntitlement, }); + if (!billingCycleAnchorMs) { + throw new InternalError({ + message: `[Quantity Update] billingCycleAnchorMs is required (no active subscription)`, + }); + } + const billingPeriod = getLineItemBillingPeriod({ anchorMs: billingCycleAnchorMs, price: customerPrice.price, diff --git a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts index 75950b93a..8c9990ad9 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan.ts @@ -1,4 +1,3 @@ -import { InternalError } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; @@ -11,14 +10,7 @@ export const computeUpdateQuantityPlan = ({ ctx: AutumnContext; updateSubscriptionContext: UpdateSubscriptionBillingContext; }): AutumnBillingPlan => { - const { customerProduct, stripeSubscription, featureQuantities } = - updateSubscriptionContext; - - if (!stripeSubscription) { - throw new InternalError({ - message: `[Subscription Update] Stripe subscription not found`, - }); - } + const { customerProduct, featureQuantities } = updateSubscriptionContext; const newOptions = featureQuantities; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleCustomPlanErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleCustomPlanErrors.ts new file mode 100644 index 000000000..72ecec011 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleCustomPlanErrors.ts @@ -0,0 +1,54 @@ +import { + cusProductToProduct, + productsAreSame, + RecaseError, + type UpdateSubscriptionV0Params, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; + +export const handleCustomPlanErrors = ({ + ctx, + billingContext, + autumnBillingPlan, + params, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionBillingContext; + autumnBillingPlan: AutumnBillingPlan; + params: UpdateSubscriptionV0Params; +}) => { + if (!params.items) return; + + const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; + const currentCustomerProduct = billingContext.customerProduct; + + const currentFullProduct = cusProductToProduct({ + cusProduct: currentCustomerProduct, + }); + + const newFullProduct = cusProductToProduct({ + cusProduct: newCustomerProduct, + }); + + const { itemsSame, onlyEntsChanged } = productsAreSame({ + curProductV1: currentFullProduct, + newProductV1: newFullProduct, + features: ctx.features, + }); + + if (itemsSame) { + throw new RecaseError({ + message: + "Cannot update to custom plan because the configuration (features and prices) are the same as the existing product", + }); + } + + if (onlyEntsChanged && billingContext.invoiceMode) { + throw new RecaseError({ + message: + "Cannot create an invoice for this subscription update because there are no billing changes.", + }); + } +}; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts index 3ed82f119..74de3bd51 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts @@ -17,9 +17,7 @@ export const handleFeatureQuantityErrors = ({ autumnBillingPlan: AutumnBillingPlan; }) => { const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; - if (!newCustomerProduct) { - return; - } + if (!newCustomerProduct) return; const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct }); const prepaidPrices = newPrices.filter(isPrepaidPrice); diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleOneOffErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleOneOffErrors.ts new file mode 100644 index 000000000..111c65b74 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleOneOffErrors.ts @@ -0,0 +1,86 @@ +import { + cusProductToProduct, + isCustomerProductOneOff, + isCustomerProductPaidRecurring, + isOneOffPrice, + productsAreSame, + RecaseError, +} from "@autumn/shared"; +import { cusProductToPrices } from "@shared/utils/cusProductUtils/convertCusProduct"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; +import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; + +export const handleOneOffErrors = ({ + ctx, + billingContext, + autumnBillingPlan, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionBillingContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const { customerProduct } = billingContext; + + // Only apply these checks to one-off products + if (!isCustomerProductOneOff(customerProduct)) return; + + const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; + if (!newCustomerProduct) return; + + const currentFullProduct = cusProductToProduct({ + cusProduct: customerProduct, + }); + + const newFullProduct = cusProductToProduct({ + cusProduct: newCustomerProduct, + }); + + const { onlyEntsChanged } = productsAreSame({ + curProductV1: currentFullProduct, + newProductV1: newFullProduct, + features: ctx.features, + }); + + if (!onlyEntsChanged) { + throw new RecaseError({ + message: + "When updating a one-off plan, price / billing changes are not allowed.", + }); + } +}; + +/** Don't allow removing a trial from a paid recurring product when adding one-off items */ +export const checkTrialRemovalWithOneOffItems = ({ + billingContext, + autumnBillingPlan, +}: { + billingContext: UpdateSubscriptionBillingContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const isPaidRecurring = isCustomerProductPaidRecurring( + billingContext.customerProduct, + ); + + if (!isPaidRecurring) return; + + const { isTrialing, willBeTrialing } = getTrialStateTransition({ + billingContext, + }); + + if (!isTrialing || willBeTrialing) return; + + const newCustomerProducts = autumnBillingPlan.insertCustomerProducts; + const newPrices = newCustomerProducts.flatMap((customerProduct) => + cusProductToPrices({ cusProduct: customerProduct }), + ); + const newHasOneOffPrices = newPrices.some(isOneOffPrice); + + if (newHasOneOffPrices) { + throw new RecaseError({ + message: + "Cannot remove trial from a paid recurring subscription when adding one-off items.", + }); + } +}; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index 9012bc4db..cdfcac7b7 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -1,18 +1,30 @@ -import { ProcessorType, RecaseError } from "@autumn/shared"; +import { + ProcessorType, + RecaseError, + type UpdateSubscriptionV0Params, +} from "@autumn/shared"; import { cusProductToProcessorType } from "@shared/utils/cusProductUtils/convertCusProduct"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; +import { handleCustomPlanErrors } from "./handleCustomPlanErrors"; import { handleFeatureQuantityErrors } from "./handleFeatureQuantityErrors"; +import { + checkTrialRemovalWithOneOffItems, + handleOneOffErrors, +} from "./handleOneOffErrors"; import { handleProductTypeTransitionErrors } from "./handleProductTypeTransitionErrors"; export const handleUpdateSubscriptionErrors = async ({ + ctx, billingContext, autumnBillingPlan, + params, }: { ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; autumnBillingPlan: AutumnBillingPlan; + params: UpdateSubscriptionV0Params; }) => { const { customerProduct } = billingContext; @@ -28,4 +40,13 @@ export const handleUpdateSubscriptionErrors = async ({ // 3. Feature quantity errors (prepaid prices must have options) handleFeatureQuantityErrors({ billingContext, autumnBillingPlan }); + + // 4. Custom plan errors + handleCustomPlanErrors({ ctx, billingContext, autumnBillingPlan, params }); + + // 5. One-off errors + handleOneOffErrors({ ctx, billingContext, autumnBillingPlan }); + + // 6. Trial removal with one-off items + checkTrialRemovalWithOneOffItems({ billingContext, autumnBillingPlan }); }; diff --git a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts index 51c47bb70..ae8af6751 100644 --- a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts @@ -1,6 +1,7 @@ import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan"; import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors"; +import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { executeBillingPlan } from "../execute/executeBillingPlan"; import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan"; @@ -30,6 +31,7 @@ export const handleUpdateSubscription = createRoute({ ctx, billingContext, autumnBillingPlan, + params: body, }); const stripeBillingPlan = await evaluateStripeBillingPlan({ @@ -38,7 +40,7 @@ export const handleUpdateSubscription = createRoute({ autumnBillingPlan, }); - await executeBillingPlan({ + const billingResult = await executeBillingPlan({ ctx, billingContext, billingPlan: { @@ -47,6 +49,13 @@ export const handleUpdateSubscription = createRoute({ }, }); - return c.json({ success: true }, 200); + const response = billingResultToResponse({ + billingContext, + billingResult, + }); + + ctx.logger.info("[handleUpdateSubscription] Completed", { response }); + + return c.json(response, 200); }, }); diff --git a/server/src/internal/billing/v2/utils/billingContext/getCurrentBillingCycleAnchorMs.ts b/server/src/internal/billing/v2/utils/billingContext/getCurrentBillingCycleAnchorMs.ts new file mode 100644 index 000000000..ff1709111 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/getCurrentBillingCycleAnchorMs.ts @@ -0,0 +1,14 @@ +import { secondsToMs } from "@autumn/shared"; +import type { BillingContext } from "../../billingContext"; + +export const getCurrentBillingCycleAnchorMs = ({ + billingContext, +}: { + billingContext: BillingContext; +}) => { + const { stripeSubscription } = billingContext; + + return stripeSubscription?.billing_cycle_anchor + ? secondsToMs(stripeSubscription.billing_cycle_anchor) + : "now"; +}; diff --git a/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts b/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts new file mode 100644 index 000000000..b25db68ec --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode.ts @@ -0,0 +1,13 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; + +export const isDeferredInvoiceMode = ({ + billingContext, +}: { + billingContext: BillingContext; +}): boolean => { + const isInvoiceMode = Boolean(billingContext.invoiceMode); + const shouldDefer = + billingContext.invoiceMode?.enableProductImmediately === false; + + return isInvoiceMode && shouldDefer; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts index 575ed1c4d..ad067085b 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -2,7 +2,6 @@ import { type BillingPreviewResponse, cp, cusProductsToPrices, - cusProductToLineItems, getCycleEnd, getSmallestInterval, sumValues, @@ -10,6 +9,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; +import { customerProductToLineItems } from "../lineItems/customerProductToLineItems"; export const billingPlanToNextCyclePreview = ({ ctx, @@ -23,10 +23,6 @@ export const billingPlanToNextCyclePreview = ({ // 1. Return undefined if billing cycle anchor is now const { billingCycleAnchorMs } = billingContext; - ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`); - - ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`); - if (billingCycleAnchorMs === "now") return undefined; const { insertCustomerProducts, updateCustomerProduct } = billingPlan.autumn; @@ -40,7 +36,10 @@ export const billingPlanToNextCyclePreview = ({ cp(customerProduct).paid().recurring().hasActiveStatus().valid, ); - const prices = cusProductsToPrices({ cusProducts: customerProducts }); + const prices = cusProductsToPrices({ + cusProducts: customerProducts, + filters: { excludeOneOffPrices: true }, + }); const smallestInterval = getSmallestInterval({ prices }); @@ -54,13 +53,14 @@ export const billingPlanToNextCyclePreview = ({ }); const autumnLineItems = customerProducts.flatMap((customerProduct) => - cusProductToLineItems({ - cusProduct: customerProduct, - nowMs: nextCycleStart, - billingCycleAnchorMs, + customerProductToLineItems({ + ctx, + customerProduct: customerProduct, + billingContext: { + ...billingContext, + currentEpochMs: nextCycleStart, + }, direction: "charge", - org: ctx.org, - logger: ctx.logger, }), ); diff --git a/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts b/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts new file mode 100644 index 000000000..b9d17b422 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts @@ -0,0 +1,38 @@ +import { type BillingResponse, stripeToAtmnAmount } from "@autumn/shared"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { BillingResult } from "@/internal/billing/v2/types/billingResult"; + +export const billingResultToResponse = ({ + billingContext, + billingResult, +}: { + billingContext: BillingContext; + billingResult: BillingResult; +}): BillingResponse => { + const { fullCustomer } = billingContext; + + const customerId = fullCustomer.id ?? fullCustomer.internal_id; + + const stripeInvoice = billingResult.stripe.stripeInvoice; + + return { + customer_id: customerId, + entity_id: fullCustomer.entity?.id, + invoice: stripeInvoice + ? { + status: stripeInvoice.status, + stripe_id: stripeInvoice.id, + total: stripeToAtmnAmount({ + amount: stripeInvoice.total, + currency: stripeInvoice.currency, + }), + currency: stripeInvoice.currency, + hosted_invoice_url: stripeInvoice.hosted_invoice_url ?? null, + } + : undefined, + payment_url: + stripeInvoice?.status === "open" && stripeInvoice.hosted_invoice_url + ? stripeInvoice.hosted_invoice_url + : null, + } satisfies BillingResponse; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts new file mode 100644 index 000000000..ec7cd062d --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts @@ -0,0 +1,132 @@ +// TODO: import these once implemented +// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem"; +// import { allocatedPriceToLineItem } from "./lineItemBuilders/allocatedPriceToLineItem"; + +import { + addCusProductToCusEnt, + cusPriceToCusEnt, + type FullCusProduct, + fixedPriceToLineItem, + getLineItemBillingPeriod, + isConsumablePrice, + isFixedPrice, + isOneOffPrice, + type LineItem, + type LineItemContext, + orgToCurrency, + usagePriceToLineItem, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "../../billingContext"; +import { getCurrentBillingCycleAnchorMs } from "../billingContext/getCurrentBillingCycleAnchorMs"; + +export type LineItemDirection = "charge" | "refund"; + +/** + * Generates line items for a customer product. + * - "charge" direction: positive amounts (for NEW product) + * - "credit" direction: negative amounts with "Unused" prefix (for OLD product) + * + * NOTE: Consumable (UsageInArrear) prices are NOT included - they're always + * positive charges for past usage and handled separately. + */ +export const customerProductToLineItems = ({ + ctx, + customerProduct, + billingContext, + direction, + priceFilters, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + direction: "charge" | "refund"; + priceFilters?: { + excludeOneOffPrices?: boolean; + }; +}): LineItem[] => { + const { billingCycleAnchorMs, currentEpochMs } = billingContext; + + const originalBillingCycleAnchorMs = getCurrentBillingCycleAnchorMs({ + billingContext, + }); + + const anchorMs = + direction === "refund" + ? originalBillingCycleAnchorMs + : billingCycleAnchorMs; + + let lineItems: LineItem[] = []; + + let filteredCustomerPrices = customerProduct.customer_prices; + if (priceFilters?.excludeOneOffPrices) { + filteredCustomerPrices = filteredCustomerPrices.filter( + (cp) => !isOneOffPrice(cp.price), + ); + } + + for (const cusPrice of filteredCustomerPrices) { + const price = cusPrice.price; + + // Calculate billing period + const billingPeriod = getLineItemBillingPeriod({ + anchorMs, + price, + nowMs: currentEpochMs, + }); + + // Build line item context + const context: LineItemContext = { + price, + product: customerProduct.product, + feature: undefined, + + billingPeriod, + direction, + billingTiming: "in_advance", + now: currentEpochMs, + currency: orgToCurrency({ org: ctx.org }), + }; + + if (isFixedPrice(price)) { + lineItems.push( + fixedPriceToLineItem({ + context, + quantity: customerProduct.quantity ?? 1, + }), + ); + continue; + } + + if (isConsumablePrice(price)) continue; + + const cusEnt = cusPriceToCusEnt({ + cusPrice, + cusEnts: customerProduct.customer_entitlements, + }); + + context.feature = cusEnt?.entitlement.feature; + + if (!cusEnt) { + throw new Error( + `[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`, + ); + } + + const cusEntWithCusProduct = addCusProductToCusEnt({ + cusEnt, + cusProduct: customerProduct, + }); + + lineItems.push( + usagePriceToLineItem({ + cusEnt: cusEntWithCusProduct, + context, + }), + ); + } + + lineItems = lineItems.filter((item) => item.amount !== 0); + + return lineItems; +}; diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts index 92bb2fe68..f8e465e1f 100644 --- a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -1,8 +1,10 @@ import { generateId, InternalError, MetadataType } from "@autumn/shared"; +import { addDays } from "date-fns"; 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 { StripeBillingStage } from "@/internal/billing/v2/types/autumnBillingPlan"; import type { BillingPlan, DeferredAutumnBillingPlanData, @@ -16,30 +18,27 @@ export const insertMetadataFromBillingPlan = async ({ ctx, billingPlan, billingContext, - enableProductAfterInvoice, - invoiceActionRequired, stripeInvoice, + expiresAt, + resumeAfter, }: { ctx: AutumnContext; billingPlan: BillingPlan; billingContext: BillingContext; - enableProductAfterInvoice?: boolean; - invoiceActionRequired?: boolean; stripeInvoice?: Stripe.Invoice; + resumeAfter: StripeBillingStage; + expiresAt: number; }) => { const id = generateId("meta"); - const type = enableProductAfterInvoice - ? MetadataType.InvoiceCheckoutV2 - : invoiceActionRequired - ? MetadataType.InvoiceActionRequiredV2 - : undefined; + const type = stripeInvoice ? MetadataType.DeferredInvoice : undefined; const data = { orgId: ctx.org.id, env: ctx.env, billingPlan, billingContext, + resumeAfter, } satisfies DeferredAutumnBillingPlanData; const metadata = await MetadataService.insert({ @@ -49,6 +48,8 @@ export const insertMetadataFromBillingPlan = async ({ type, stripe_invoice_id: stripeInvoice?.id, data, + created_at: Date.now(), + expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(), }, }); diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index ffa637e1d..6393f5dd7 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -340,12 +340,21 @@ export const initHatchetWorker = async () => { return; } - console.log("Starting hatchet worker"); + try { + console.log("Starting hatchet worker"); - const worker = await hatchet.worker("hatchet-worker", { - workflows: [verifyCacheConsistencyWorkflow!], - }); + const worker = await hatchet.worker("hatchet-worker", { + workflows: [verifyCacheConsistencyWorkflow!], + }); - // Don't await - start() runs indefinitely and would block the rest of the code - worker.start(); + // Don't await - start() runs indefinitely and would block the rest of the code + // But catch errors to prevent unhandled promise rejections from crashing + worker.start().catch((error) => { + console.error("Hatchet worker error (non-fatal):", error.message); + Sentry.captureException(error); + }); + } catch (error) { + console.error("Failed to start hatchet worker", error); + Sentry.captureException(error); + } }; diff --git a/server/tests/billing/update-subscription/custom-plan/update-one-off-mixed.test.ts b/server/tests/billing/update-subscription/custom-plan/update-one-off-mixed.test.ts new file mode 100644 index 000000000..5dc7bb735 --- /dev/null +++ b/server/tests/billing/update-subscription/custom-plan/update-one-off-mixed.test.ts @@ -0,0 +1,434 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { FreeTrialDuration } from "autumn-js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// MIXED ONE-OFF AND RECURRING PRODUCT UPDATES +// +// These tests cover updates that involve both recurring and one-off components. +// Tests the interaction between recurring base prices and one-off prepaid items. +// +// Test scenarios: +// - Free product → Recurring product with one-off prepaid item +// - Recurring product → Same product + one-off prepaid item +// - Product with one-off prepaid → Updated product retaining one-off prepaid +// ═══════════════════════════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: FREE → RECURRING + ONE-OFF +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("mixed: free product → recurring product with one-off prepaid item")}`, async () => { + const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProduct = products.base({ + items: [freeMessagesItem], + id: "free", + isDefault: true, + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "free-to-recurring-with-oneoff", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeProduct] }), + ], + actions: [s.attach({ productId: freeProduct.id })], + }); + + // Update to recurring product with base price + one-off prepaid messages + const monthlyBasePrice = items.monthlyPrice({ price: 20 }); + const dashboardItem = items.dashboard(); + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const quantity = 200; // 2 packs of one-off messages + + const updateParams = { + customer_id: customerId, + product_id: freeProduct.id, + items: [monthlyBasePrice, dashboardItem, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Base price ($20) + one-off messages (2 packs * $10 = $20) = $40 + const expectedTotal = 20 + (quantity / 100) * 10; + expect(preview.total).toBe(expectedTotal); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Verify dashboard feature enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + // Verify one-off messages quantity + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity, + usage: 0, + }); + + // Should have 2 invoices: initial free attach + update with charges + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: preview.total, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: RECURRING → SAME RECURRING + ONE-OFF +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("mixed: recurring product → same product + one-off prepaid item")}`, async () => { + const dashboardItem = items.dashboard(); + const monthlyBasePrice = items.monthlyPrice({ price: 20 }); + + const proProduct = products.base({ + items: [dashboardItem, monthlyBasePrice], + id: "pro", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "recurring-add-oneoff", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proProduct] }), + ], + actions: [s.attach({ productId: proProduct.id, timeout: 4000 })], + }); + + // Update to add one-off prepaid messages + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const quantity = 300; // 3 packs + + const updateParams = { + customer_id: customerId, + product_id: proProduct.id, + items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge for one-off messages only + const expectedCharge = (quantity / 100) * 10; // 3 packs * $10 + expect(preview.total).toBe(expectedCharge); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Dashboard should still be enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + // Total messages = free monthly (100) + one-off quantity (300) + // Usage preserved from free messages + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity, + usage: 0, + }); + + // Should have 2 invoices: initial pro attach + one-off purchase + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2B: RECURRING → SAME RECURRING + ONE-OFF (MID-CYCLE) +// One-off prices should NOT be prorated, so full price even mid-cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("mixed: recurring + one-off mid-cycle (no proration on one-off)")}`, async () => { + const dashboardItem = items.dashboard(); + const monthlyBasePrice = items.monthlyPrice({ price: 20 }); + + const proProduct = products.base({ + items: [dashboardItem, monthlyBasePrice], + id: "pro", + }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "recurring-oneoff-midcycle", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proProduct] }), + ], + actions: [s.attach({ productId: proProduct.id })], + }); + + // Advance 15 days (mid-cycle) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 15, + }); + + // Update to add one-off prepaid messages mid-cycle + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const quantity = 300; // 3 packs + + const updateParams = { + customer_id: customerId, + product_id: proProduct.id, + items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // One-off prices should NOT be prorated - full price even mid-cycle + const expectedCharge = (quantity / 100) * 10; // 3 packs * $10 = $30 + expect(preview.total).toBe(expectedCharge); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Dashboard should still be enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + // One-off messages should be available + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity, + usage: 0, + }); + + // Should have 2 invoices: initial pro attach + one-off purchase + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: RECURRING WITH ONE-OFF → UPDATED RECURRING WITH SAME ONE-OFF +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("mixed: recurring with one-off → updated recurring retaining one-off")}`, async () => { + const dashboardItem = items.dashboard(); + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const proProduct = products.pro({ + items: [dashboardItem, oneOffMessagesItem], + id: "pro-v1", + }); + + const initialQuantity = 200; // 2 packs + + const { customerId, autumnV1 } = await initScenario({ + customerId: "recurring-with-oneoff-update", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proProduct] }), + ], + actions: [ + s.attach({ + productId: proProduct.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + ], + }); + + // Track some usage + const messagesUsed = 100; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Update to new product version with: + // - Increased base price + // - Added feature (monthly words) + // - Same one-off prepaid messages (same quantity) + const monthlyBasePrice = items.monthlyPrice({ price: 30 }); // Increased from $20 + const wordsItem = items.monthlyWords({ includedUsage: 50 }); // New feature + + const updateParams = { + customer_id: customerId, + product_id: proProduct.id, + items: [monthlyBasePrice, dashboardItem, wordsItem, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // One-off items are charged again on update + // One-off messages: (200 / 100) * $10 = $20 + // Base price increase: $30 - $20 = $10 (full cycle remaining) + const oneOffCharge = (initialQuantity / 100) * 10; + const basePriceDiff = 30 - 20; + const expectedTotal = oneOffCharge + basePriceDiff; + expect(preview.total).toBe(expectedTotal); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Dashboard should still be enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + // New words feature should be available + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 50, + balance: 50, + usage: 0, + }); + + // One-off messages should retain usage and quantity + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: initialQuantity, + balance: initialQuantity - messagesUsed, + usage: messagesUsed, + }); + + // Should have 2 invoices: initial attach with one-off + update with price increase + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: FREE → TRIAL WITH ONE-OFF ITEM +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("mixed: free → trial product with one-off prepaid item")}`, async () => { + const dashboardItem = items.dashboard(); + const monthlyBasePrice = items.monthlyPrice({ price: 20 }); + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const freeProduct = products.base({ + id: "free-starter", + items: [dashboardItem], + isDefault: false, + }); + + // const proTrial = products.base({ + // items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem], + // id: "pro-trial-oneoff", + // trialDays: 14, + // }); + + const quantity = 200; // 2 packs + + const { customerId, autumnV1 } = await initScenario({ + customerId: "free-to-trial-oneoff", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [freeProduct] }), + ], + actions: [s.attach({ productId: freeProduct.id })], + }); + + // Update from free → trial product with one-off item + const updateParams = { + customer_id: customerId, + product_id: freeProduct.id, + items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: true, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // One-off should be charged immediately, base price deferred until trial ends + const oneOffCharge = (quantity / 100) * 10; + expect(preview.total).toBe(oneOffCharge); // $20 + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Dashboard should be enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + // One-off messages should be available + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity, + usage: 0, + }); + + // Should have 1 invoice for the one-off charge + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: preview.total, + }); +}); diff --git a/server/tests/billing/update-subscription/errors/update-custom-plan-errors.test.ts b/server/tests/billing/update-subscription/errors/update-custom-plan-errors.test.ts new file mode 100644 index 000000000..bec0c48be --- /dev/null +++ b/server/tests/billing/update-subscription/errors/update-custom-plan-errors.test.ts @@ -0,0 +1,94 @@ +import { test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CUSTOM PLAN ERRORS - Same Configuration +// ═══════════════════════════════════════════════════════════════════════════════ + +// 1. Cannot update with same items (free product) +test.concurrent(`${chalk.yellowBright("error: custom plan same config (free)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-custom-same-free", + setup: [s.customer({}), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: "base" })], + }); + + // Try to update with identical items - should fail + const updateParams = { + customer_id: customerId, + product_id: freeProd.id, + items: [messagesItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 2. Cannot update with same items (paid product) +test.concurrent(`${chalk.yellowBright("error: custom plan same config (paid)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ items: [messagesItem, priceItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-custom-same-paid", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Try to update with identical items - should fail + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, priceItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 3. Cannot update with same items (multiple features) +test.concurrent(`${chalk.yellowBright("error: custom plan same config (multi-feature)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 50 }); + const prod = products.base({ + items: [messagesItem, creditsItem], + id: "multi-feature", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-custom-same-multi", + setup: [s.customer({}), s.products({ list: [prod] })], + actions: [s.attach({ productId: "multi-feature" })], + }); + + // Try to update with identical items - should fail + const updateParams = { + customer_id: customerId, + product_id: prod.id, + items: [messagesItem, creditsItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); diff --git a/server/tests/billing/update-subscription/errors/update-errors-basic.test.ts b/server/tests/billing/update-subscription/errors/update-errors-basic.test.ts index 732f93746..31440491b 100644 --- a/server/tests/billing/update-subscription/errors/update-errors-basic.test.ts +++ b/server/tests/billing/update-subscription/errors/update-errors-basic.test.ts @@ -169,3 +169,45 @@ test.concurrent(`${chalk.yellowBright("error: one-off to paid recurring transiti }, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// INVOICE MODE ERRORS +// ═══════════════════════════════════════════════════════════════════════════════ + +// 5. Cannot use invoice mode when there's no billing change +test.concurrent(`${chalk.yellowBright("error: invoice mode with no billing change")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-inv-no-billing", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Add boolean feature - no price change + const adminRightsItem = items.adminRights(); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, priceItem, adminRightsItem], + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }; + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); diff --git a/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts b/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts index e69de29bb..4cbe51346 100644 --- a/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts +++ b/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts @@ -0,0 +1,253 @@ +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ONE-OFF ERRORS - Price/Billing Changes Not Allowed +// ═══════════════════════════════════════════════════════════════════════════════ + +// 1. Error: changing base price on one-off product +test.concurrent(`${chalk.yellowBright("error: one-off changing base price")}`, async () => { + const oneOffPriceItem = items.oneOffPrice({ price: 50 }); + const oneOffProd = products.base({ items: [oneOffPriceItem], id: "oneoff" }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-oneoff-base-price", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [s.attach({ productId: "oneoff" })], + }); + + // Try to change base price - should fail + const newPriceItem = items.oneOffPrice({ price: 100 }); + const updateParams = { + customer_id: customerId, + product_id: oneOffProd.id, + items: [newPriceItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 2. Error: changing feature price on one-off prepaid product +test.concurrent(`${chalk.yellowBright("error: one-off changing feature price")}`, async () => { + const oneOffMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits: 100, + }); + const oneOffProd = products.base({ + items: [oneOffMessagesItem], + id: "oneoff-feature", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-oneoff-feature-price", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: "oneoff-feature", + options: [{ feature_id: TestFeature.Messages, quantity: 1 }], + }), + ], + }); + + // Try to change feature price - should fail + const newMessagesItem = items.oneOffMessages({ + price: 20, + billingUnits: 100, + }); + const updateParams = { + customer_id: customerId, + product_id: oneOffProd.id, + items: [newMessagesItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 3. Error: changing billing units on one-off prepaid product +test.concurrent(`${chalk.yellowBright("error: one-off changing billing units")}`, async () => { + const oneOffMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits: 100, + }); + const oneOffProd = products.base({ + items: [oneOffMessagesItem], + id: "oneoff-billing", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-oneoff-billing-units", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: "oneoff-billing", + options: [{ feature_id: TestFeature.Messages, quantity: 1 }], + }), + ], + }); + + // Try to change billing units - should fail + const newMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits: 200, + }); + const updateParams = { + customer_id: customerId, + product_id: oneOffProd.id, + items: [newMessagesItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 4. Error: changing both price and billing units on one-off +test.concurrent(`${chalk.yellowBright("error: one-off changing price and billing units")}`, async () => { + const oneOffMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits: 100, + }); + const oneOffProd = products.base({ + items: [oneOffMessagesItem], + id: "oneoff-both", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-oneoff-both", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: "oneoff-both", + options: [{ feature_id: TestFeature.Messages, quantity: 1 }], + }), + ], + }); + + // Try to change both price and billing units - should fail + const newMessagesItem = items.oneOffMessages({ + price: 20, + billingUnits: 200, + }); + const updateParams = { + customer_id: customerId, + product_id: oneOffProd.id, + items: [newMessagesItem], + }; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update(updateParams); + }, + }); +}); + +// 5. Error: updating quantity of one-off item on recurring product +test.concurrent(`${chalk.yellowBright("error: one-off item quantity update on recurring product")}`, async () => { + const billingUnits = 100; + const oneOffMessagesItem = items.oneOffMessages({ + price: 10, + billingUnits, + }); + const recurringProduct = products.base({ + items: [items.monthlyPrice({ price: 20 }), oneOffMessagesItem], + id: "recurring-with-oneoff", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-oneoff-qty-recurring", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [recurringProduct] }), + ], + actions: [ + s.attach({ + productId: recurringProduct.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 1 * billingUnits }, + ], + }), + ], + }); + + // Try to update quantity of one-off messages - should fail + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: recurringProduct.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 2 * billingUnits }, + ], + }); + }, + }); +}); + +// 6. Error: removing trial and adding one-off item +test.concurrent(`${chalk.yellowBright("error: remove trial and add one-off item")}`, async () => { + const dashboardItem = items.dashboard(); + const monthlyBasePrice = items.monthlyPrice({ price: 20 }); + + const proTrial = products.base({ + items: [dashboardItem, monthlyBasePrice], + id: "pro-trial", + trialDays: 7, + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-trial-remove-add-oneoff", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.attach({ productId: proTrial.id })], + }); + + // Try to remove trial and add one-off prepaid messages - should fail + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const quantity = 300; + + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + items: [dashboardItem, monthlyBasePrice, oneOffMessagesItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }); + }, + }); +}); diff --git a/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts b/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts index 0b50dc206..09df3f82c 100644 --- a/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts +++ b/server/tests/billing/update-subscription/free-trial/update-paid-trials.test.ts @@ -48,7 +48,6 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}` const updateParams = { customer_id: customerId, product_id: proTrial.id, - items: [messagesItem, items.monthlyPrice()], free_trial: null, }; diff --git a/server/tests/billing/update-subscription/invoice-mode/update-invoice-deferred.test.ts b/server/tests/billing/update-subscription/invoice-mode/update-invoice-deferred.test.ts new file mode 100644 index 000000000..53fb8b54c --- /dev/null +++ b/server/tests/billing/update-subscription/invoice-mode/update-invoice-deferred.test.ts @@ -0,0 +1,585 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Invoice Mode Tests - Deferred Plan Activation + * + * These tests verify invoice mode with deferred plan activation: + * - invoice: true + * - enable_product_immediately: false (plan activates after payment) + * - finalize_invoice: true (invoice is finalized and sent) + * + * Cases: + * - Case 1: Increase price (finalized, deferred) + * - Case 2: Decrease price (finalized, deferred) + * - Case 3: Free → paid (finalized, deferred) + * - Case 4: Trial removal (finalized, deferred) + * - Case 5: Increase quantity (finalized, deferred) + * - Case 6: Increase price (draft, deferred) - finalize_invoice: false + * - Case 7: Free → paid (draft, deferred) - finalize_invoice: false + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 1: INCREASE PRICE - FINALIZED INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: increase price (finalized, deferred)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-increase-fin", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Increase price from $20 to $30 AND increase messages from 100 to 200 + const newMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const newPriceItem = items.monthlyPrice({ price: 30 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [newMessagesItem, newPriceItem], + invoice: true, + enable_product_immediately: false, + finalize_invoice: true, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $10 difference ($30 - $20) + expect(preview.total).toBe(10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult).toMatchObject({ + payment_url: expect.any(String), + invoice: { + status: "open", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }, + action_required: undefined, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("open"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "open", + }); + + // Before payment - balance should still be 100 (original) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 100, + }); + + await completeInvoiceCheckout({ + url: result.payment_url, + }); + + const customerAfterPayment = + await autumnV1.customers.get(customerId); + + expectProductActive({ + customer: customerAfterPayment, + productId: pro.id, + }); + + // After payment - balance should be 200 (new plan) + expectCustomerFeatureCorrect({ + customer: customerAfterPayment, + featureId: TestFeature.Messages, + balance: 200, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 2: DECREASE PRICE - FINALIZED INVOICE, DEFERRED ACTIVATION +// Note: Invoice is auto-paid by Stripe because the total is negative (credit) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: decrease price (finalized, deferred)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 30 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-decrease-fin", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Decrease price from $30 to $20 AND increase messages from 100 to 200 + const newMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const newPriceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [newMessagesItem, newPriceItem], + invoice: true, + enable_product_immediately: false, + finalize_invoice: true, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be -$10 credit (price decrease) + expect(preview.total).toEqual(-10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult).toMatchObject({ + payment_url: null, + invoice: expect.objectContaining({ + status: "paid", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }), + }); + expect(clonedResult.action_required).toBeUndefined(); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "paid", + }); + + // After auto-paid invoice - balance should be 200 (new plan) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 3: FREE → PAID - FINALIZED INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: free → paid (finalized, deferred)")}`, async () => { + const dashboardItem = items.dashboard(); + const freeProduct = products.base({ + id: "free", + items: [dashboardItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-free-paid-fin", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeProduct] }), + ], + actions: [s.attach({ productId: freeProduct.id })], + }); + + // Update from free to paid with monthly price + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: freeProduct.id, + items: [dashboardItem, messagesItem, priceItem], + invoice: true, + enable_product_immediately: false, + finalize_invoice: true, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $20 (full price for new paid plan) + expect(preview.total).toBe(20); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult).toMatchObject({ + payment_url: expect.any(String), + invoice: expect.objectContaining({ + status: "open", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }), + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("open"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only the update invoice (free attach has no invoice) + latestTotal: preview.total, + latestStatus: "open", + }); + + // Before payment - messages feature should not exist (free product) + expect(customer.features?.[TestFeature.Messages]).toBeUndefined(); + + await completeInvoiceCheckout({ + url: result.payment_url, + }); + + const customerAfterPayment = + await autumnV1.customers.get(customerId); + + expectProductActive({ + customer: customerAfterPayment, + productId: freeProduct.id, + }); + + // After payment - balance should be 100 (new paid plan) + expectCustomerFeatureCorrect({ + customer: customerAfterPayment, + featureId: TestFeature.Messages, + balance: 100, + }); + + expectCustomerInvoiceCorrect({ + customer: customerAfterPayment, + count: 1, // Only the update invoice (free attach has no invoice) + latestTotal: preview.total, + latestStatus: "paid", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 4: TRIAL REMOVAL - FINALIZED INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: remove trial (finalized, deferred)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const proTrial = products.base({ + id: "pro-trial", + items: [messagesItem, priceItem], + trialDays: 7, + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-trial-fin", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.attach({ productId: proTrial.id })], + }); + + // Remove trial by passing free_trial: null + const updateParams = { + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + invoice: true, + enable_product_immediately: false, + finalize_invoice: true, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge full price ($20) since trial is being removed + expect(preview.total).toBe(20); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "open", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("open"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial trial attach ($0) + update + latestTotal: preview.total, + latestStatus: "open", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 5: INCREASE QUANTITY - FINALIZED INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: increase quantity (finalized, deferred)")}`, async () => { + const billingUnits = 12; + const pricePerUnit = 8; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }); + + const product = products.base({ + id: "prepaid", + items: [prepaidItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-qty-fin", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }), + ], + }); + + const updateParams = { + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 20 * billingUnits }, + ], + invoice: true, + enable_product_immediately: false, + finalize_invoice: true, + }; + + // Preview the upgrade + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge for +10 units (10 * $8 = $80) + expect(preview.total).toBe(10 * pricePerUnit); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "open", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("open"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "open", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 6: INCREASE PRICE - DRAFT INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: increase price (draft, deferred)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-increase-draft", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Increase price from $20 to $30 + const newPriceItem = items.monthlyPrice({ price: 30 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, newPriceItem], + invoice: true, + enable_product_immediately: false, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $10 difference ($30 - $20) + expect(preview.total).toBe(10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 7: FREE → PAID - DRAFT INVOICE, DEFERRED ACTIVATION +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-deferred: free → paid (draft, deferred)")}`, async () => { + const dashboardItem = items.dashboard(); + const freeProduct = products.base({ + id: "free", + items: [dashboardItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-def-free-paid-draft", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeProduct] }), + ], + actions: [s.attach({ productId: freeProduct.id })], + }); + + // Update from free to paid with monthly price + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: freeProduct.id, + items: [dashboardItem, messagesItem, priceItem], + invoice: true, + enable_product_immediately: false, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $20 (full price for new paid plan) + expect(preview.total).toBe(20); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only the update invoice (free attach has no invoice) + latestTotal: preview.total, + latestStatus: "draft", + }); +}); diff --git a/server/tests/billing/update-subscription/invoice-mode/update-with-invoice-basic.test.ts b/server/tests/billing/update-subscription/invoice-mode/update-with-invoice-basic.test.ts new file mode 100644 index 000000000..061afab3f --- /dev/null +++ b/server/tests/billing/update-subscription/invoice-mode/update-with-invoice-basic.test.ts @@ -0,0 +1,354 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Invoice Mode Tests for Price Updates + * + * These tests verify invoice mode configurations when updating subscription prices: + * - Case 1: Increase price with draft invoice, immediate entitlements + * - Case 2: Decrease price with draft invoice, immediate entitlements + * - Case 2B: Free → paid with draft invoice, immediate entitlements + * - Case 3: Trial removal with draft invoice, immediate entitlements + * - Case 4: Increase price with finalized invoice, immediate entitlements + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 1: INCREASE PRICE - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-mode: increase price (draft, immediate)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-increase-draft", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Increase price from $20 to $30 + const newPriceItem = items.monthlyPrice({ price: 30 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, newPriceItem], + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $10 difference ($30 - $20) + expect(preview.total).toBe(10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 2: DECREASE PRICE - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-mode: decrease price (draft, immediate)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 30 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-decrease-draft", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Decrease price from $30 to $20 + const newPriceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, newPriceItem], + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be $0 or credit (price decrease) + expect(preview.total).toEqual(-10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 2B: FREE → PAID - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-mode: free → paid (draft, immediate)")}`, async () => { + const dashboardItem = items.dashboard(); + const freeProduct = products.base({ + id: "free", + items: [dashboardItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-free-to-paid-draft", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeProduct] }), + ], + actions: [s.attach({ productId: freeProduct.id })], + }); + + // Update from free to paid with monthly price + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: freeProduct.id, + items: [dashboardItem, messagesItem, priceItem], + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $20 (full price for new paid plan) + expect(preview.total).toBe(20); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only the update invoice (free attach has no invoice) + latestTotal: preview.total, + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 3: TRIAL REMOVAL - DRAFT INVOICE, IMMEDIATE ENTITLEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-mode: remove trial (draft, immediate)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const proTrial = products.base({ + id: "pro-trial", + items: [messagesItem, priceItem], + trialDays: 7, + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-remove-trial-draft", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.attach({ productId: proTrial.id })], + }); + + // Remove trial by passing free_trial: null + const updateParams = { + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + invoice: true, + enable_product_immediately: true, + finalize_invoice: false, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge full price ($20) since trial is being removed + expect(preview.total).toBe(20); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "draft", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: null, + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial trial attach ($0) + update + latestTotal: preview.total, + latestStatus: "draft", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 4: INCREASE PRICE - FINALIZED INVOICE, IMMEDIATE ENTITLEMENTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("invoice-mode: increase price (finalized, immediate)")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "inv-increase-finalized", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Increase price from $20 to $30 + const newPriceItem = items.monthlyPrice({ price: 30 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, newPriceItem], + invoice: true, + enable_product_immediately: true, + finalize_invoice: true, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $10 difference ($30 - $20) + expect(preview.total).toBe(10); + + const result = await autumnV1.subscriptions.update(updateParams); + const clonedResult = structuredClone(result); + + expect(clonedResult.invoice).toBeDefined(); + expect(clonedResult.invoice).toMatchObject({ + status: "open", + stripe_id: expect.any(String), + total: preview.total, + hosted_invoice_url: expect.any(String), + }); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice.stripe_id, + ); + expect(stripeInvoice.status).toBe("open"); + expect(stripeInvoice.total).toBe( + atmnToStripeAmount({ amount: preview.total, currency: "usd" }), + ); + expect(stripeInvoice.hosted_invoice_url).toBeTruthy(); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, // Initial attach + update + latestTotal: preview.total, + latestStatus: "open", + }); +}); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 676fca1fb..ef293ca6e 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -26,6 +26,16 @@ const dashboard = () => isBoolean: true, }); +/** + * Boolean feature - admin rights access + * @returns AdminRights feature item + */ +const adminRights = () => + constructFeatureItem({ + featureId: TestFeature.AdminRights, + isBoolean: true, + }); + // ═══════════════════════════════════════════════════════════════════ // FREE METERED (included usage, resets monthly) // ═══════════════════════════════════════════════════════════════════ @@ -270,6 +280,16 @@ const annualPrice = ({ price = 200 }: { price?: number } = {}) => interval: BillingInterval.Year, }); +/** + * One-off base price item (no recurring charges) + * @param price - One-time price (default: 50) + */ +const oneOffPrice = ({ price = 50 }: { price?: number } = {}) => + constructPriceItem({ + price, + interval: null, + }); + // ═══════════════════════════════════════════════════════════════════ // EXPORT // ═══════════════════════════════════════════════════════════════════ @@ -277,6 +297,7 @@ const annualPrice = ({ price = 200 }: { price?: number } = {}) => export const items = { // Boolean dashboard, + adminRights, // Free metered monthlyMessages, @@ -303,4 +324,5 @@ export const items = { // Base prices monthlyPrice, annualPrice, + oneOffPrice, } as const; diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index 582880090..05dbe57c9 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -13,20 +13,31 @@ import { * @param items - Product items (features) * @param id - Product ID (default: "base") * @param isDefault - Whether this is a default product (default: false) + * @param trialDays - Optional number of trial days */ const base = ({ items, id = "base", isDefault = false, isAddOn = false, + trialDays, }: { items: ProductItem[]; id?: string; isDefault?: boolean; isAddOn?: boolean; + trialDays?: number; }): ProductV2 => ({ ...constructRawProduct({ id, items, isAddOn }), is_default: isDefault, + ...(trialDays && { + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: true, + }, + }), }); /** diff --git a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts index ce00e8b58..acbf4d1d2 100644 --- a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts +++ b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts @@ -14,6 +14,8 @@ export const completeInvoiceCheckout = async ({ url: string; isLocal?: boolean; }) => { + console.log("[completeInvoiceCheckout] Starting invoice checkout..."); + let browser: Browser; browser = await puppeteer.launch({ @@ -183,6 +185,7 @@ export const completeInvoiceCheckout = async ({ } } await timeout(20000); + console.log("[completeInvoiceCheckout] Invoice checkout completed"); } finally { // always close browser await browser.close(); diff --git a/shared/api/billing/common/billingResponse.ts b/shared/api/billing/common/billingResponse.ts new file mode 100644 index 000000000..fa563b539 --- /dev/null +++ b/shared/api/billing/common/billingResponse.ts @@ -0,0 +1,35 @@ +import { z } from "zod/v4"; + +export const PaymentFailureCodeEnum = z.enum([ + "3ds_required", + "payment_method_required", + "payment_failed", +]); + +export type PaymentFailureCode = z.infer; + +export const BillingResponseSchema = z.object({ + customer_id: z.string(), + entity_id: z.string().optional(), + + invoice: z + .object({ + status: z.string().nullable(), + stripe_id: z.string(), + total: z.number(), + currency: z.string(), + hosted_invoice_url: z.string().nullable(), + }) + .optional(), + + payment_url: z.string().nullable(), + + required_action: z + .object({ + code: PaymentFailureCodeEnum, + reason: z.string(), + }) + .optional(), +}); + +export type BillingResponse = z.infer; diff --git a/shared/api/billing/index.ts b/shared/api/billing/index.ts index 32b6be88d..03c4c8ec8 100644 --- a/shared/api/billing/index.ts +++ b/shared/api/billing/index.ts @@ -10,6 +10,7 @@ export * from "./checkout/prevVersions/checkoutResponseV0.js"; // Common export * from "./common/billingPreviewResponse.js"; +export * from "./common/billingResponse.js"; export * from "./updateSubscription/previewUpdateSubscriptionResponse.js"; // Update Subscription diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index cc0f88048..fce8efa43 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -33,21 +33,35 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({ export const UpdateSubscriptionV0ParamsSchema = ExtUpdateSubscriptionV0ParamsSchema.extend({ customer_product_id: z.string().optional(), - }).check((ctx) => { - if (ctx.value.options && ctx.value.options.length > 0) { - const invalidFeatures = ctx.value.options - .filter((opt) => nullish(opt.quantity) || opt.quantity < 0) - .map((opt) => opt.feature_id); + }) + .refine( + (data) => { + if (data.items && data.items.length === 0) { + return false; + } - if (invalidFeatures.length > 0) { - ctx.issues.push({ - code: "custom", - message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`, - input: ctx.value, - }); + return true; + }, + { + message: + "Must provide at least one item when updating to a custom plan", + }, + ) + .check((ctx) => { + if (ctx.value.options && ctx.value.options.length > 0) { + const invalidFeatures = ctx.value.options + .filter((opt) => nullish(opt.quantity) || opt.quantity < 0) + .map((opt) => opt.feature_id); + + if (invalidFeatures.length > 0) { + ctx.issues.push({ + code: "custom", + message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`, + input: ctx.value, + }); + } } - } - }); + }); export type ExtUpdateSubscriptionV0Params = z.infer< typeof ExtUpdateSubscriptionV0ParamsSchema diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index 0d2212057..cb02da3a0 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -6,10 +6,11 @@ export enum MetadataType { InvoiceActionRequired = "invoice_action_required", InvoiceCheckout = "invoice_checkout", CheckoutSessionCompleted = "checkout_session_completed", - DeferredAutumnBillingPlan = "deferred_autumn_billing_plan", - InvoiceActionRequiredV2 = "invoice_action_required_v2", - InvoiceCheckoutV2 = "invoice_checkout_v2", + DeferredInvoice = "deferred_invoice", + + // InvoiceActionRequiredV2 = "invoice_action_required_v2", + // InvoiceCheckoutV2 = "invoice_checkout_v2", } export const metadata = pgTable("metadata", { diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 1db7603fb..c683772af 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -1,13 +1,13 @@ export * from "./cycleUtils/getCycleEnd.js"; export * from "./cycleUtils/getCycleStart.js"; - +export * from "./cycleUtils/getLineItemBillingPeriod.js"; // Interval utils export * from "./intervalUtils/addDuration.js"; export * from "./intervalUtils/intervalArithmetic.js"; // Invoicing utils export * from "./invoicingUtils/cusProductToArrearLineItems.js"; -export * from "./invoicingUtils/cusProductToLineItems.js"; + export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js"; export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js"; export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js"; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts deleted file mode 100644 index 78eb7dd8b..000000000 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem"; -import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext"; -import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels"; -import type { Organization } from "../../../models/orgModels/orgTable"; -import { addCusProductToCusEnt } from "../../cusEntUtils/cusEntUtils"; -import { cusPriceToCusEnt } from "../../cusPriceUtils/convertCusPriceUtils"; -import { orgToCurrency } from "../../orgUtils/convertOrgUtils"; -import { - isConsumablePrice, - isFixedPrice, -} from "../../productUtils/priceUtils/classifyPriceUtils"; -import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod"; -import { fixedPriceToLineItem } from "./lineItemBuilders/fixedPriceToLineItem"; -import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem"; - -// TODO: import these once implemented -// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem"; -// import { allocatedPriceToLineItem } from "./lineItemBuilders/allocatedPriceToLineItem"; - -export type LineItemDirection = "charge" | "refund"; - -/** - * Generates line items for a customer product. - * - "charge" direction: positive amounts (for NEW product) - * - "credit" direction: negative amounts with "Unused" prefix (for OLD product) - * - * NOTE: Consumable (UsageInArrear) prices are NOT included - they're always - * positive charges for past usage and handled separately. - */ -export const cusProductToLineItems = ({ - cusProduct, - nowMs, - billingCycleAnchorMs, - direction, - org, - logger, -}: { - cusProduct: FullCusProduct; - nowMs: number; - billingCycleAnchorMs: number | "now"; - direction: "charge" | "refund"; - org: Organization; - // biome-ignore lint/suspicious/noExplicitAny: Logger type defined in server package - logger: any; -}): LineItem[] => { - let lineItems: LineItem[] = []; - - // logger.debug( - // `Building line items for customer product: ${cusProduct.product.id} (${direction})`, - // ); - // logger.debug( - // `Billing cycle anchor: ${formatMs(billingCycleAnchorMs)}, now: ${formatMs(nowMs)}`, - // ); - - for (const cusPrice of cusProduct.customer_prices) { - const price = cusPrice.price; - - // Calculate billing period - const billingPeriod = getLineItemBillingPeriod({ - anchorMs: billingCycleAnchorMs, - price, - nowMs, - }); - - // logger.debug( - // `Billing period: ${formatMs(billingPeriod?.start)} - ${formatMs(billingPeriod?.end)}`, - // ); - - // Build line item context - const context: LineItemContext = { - price, - product: cusProduct.product, - feature: undefined, - - billingPeriod, - direction, - billingTiming: "in_advance", - now: nowMs, - currency: orgToCurrency({ org }), - }; - - if (isFixedPrice(price)) { - lineItems.push( - fixedPriceToLineItem({ - context, - quantity: cusProduct.quantity ?? 1, - }), - ); - continue; - } - - if (isConsumablePrice(price)) continue; - - const cusEnt = cusPriceToCusEnt({ - cusPrice, - cusEnts: cusProduct.customer_entitlements, - }); - - context.feature = cusEnt?.entitlement.feature; - - if (!cusEnt) { - throw new Error( - `[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`, - ); - } - - const cusEntWithCusProduct = addCusProductToCusEnt({ - cusEnt, - cusProduct, - }); - - lineItems.push( - usagePriceToLineItem({ - cusEnt: cusEntWithCusProduct, - context, - }), - ); - } - - lineItems = lineItems.filter((item) => item.amount !== 0); - - return lineItems; -}; diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index e0b14ebb0..b3dc4cdd9 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -51,6 +51,15 @@ export const isCustomerProductPaid = (customerProduct?: FullCusProduct) => { return !isFreeProduct({ prices }); }; +/** Customer product is both paid AND recurring (not free, not one-off) */ +export const isCustomerProductPaidRecurring = ( + customerProduct?: FullCusProduct, +) => { + if (!customerProduct) return false; + const prices = cusProductToPrices({ cusProduct: customerProduct }); + return !isFreeProduct({ prices }) && !isOneOffProduct({ prices }); +}; + // ============================================================================ // STATUS CHECKS // ============================================================================ diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts index a6ed1f743..bf6b4b778 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts @@ -13,6 +13,7 @@ import { isCustomerProductOnStripeSubscription, isCustomerProductOnStripeSubscriptionSchedule, isCustomerProductPaid, + isCustomerProductPaidRecurring, isCustomerProductRecurring, isCustomerProductScheduled, isCustomerProductTrialing, @@ -82,6 +83,12 @@ class CustomerProductChecker { return this; } + /** Product is paid AND recurring (not free, not one-off) */ + paidRecurring() { + this.predicates.push(isCustomerProductPaidRecurring); + return this; + } + /** Product has canceled_at set */ canceling() { this.predicates.push(isCustomerProductCanceling); diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index 40f2002da..d308c7271 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -1,3 +1,4 @@ +import { isOneOffPrice } from "@utils/productUtils/priceUtils/classifyPriceUtils.js"; import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; import type { CustomerEntitlementFilters } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; @@ -17,10 +18,20 @@ import { notNullish } from "../utils.js"; export const cusProductsToPrices = ({ cusProducts, + filters, }: { cusProducts: FullCusProduct[]; + filters?: { + excludeOneOffPrices?: boolean; + }; }) => { - return cusProducts.flatMap((cp) => cusProductToPrices({ cusProduct: cp })); + let prices = cusProducts.flatMap((cp) => + cusProductToPrices({ cusProduct: cp }), + ); + if (filters?.excludeOneOffPrices) { + prices = prices.filter((p) => !isOneOffPrice(p)); + } + return prices; }; export const cusProductsToCusPrices = ({ @@ -121,7 +132,10 @@ export const cusProductsToCusEnts = ({ // customerEntitlementFilters, }); - if (customerEntitlementFilters?.cusEntIds && customerEntitlementFilters.cusEntIds.length > 0) { + if ( + customerEntitlementFilters?.cusEntIds && + customerEntitlementFilters.cusEntIds.length > 0 + ) { cusEnts = cusEnts.filter((cusEnt) => customerEntitlementFilters.cusEntIds?.includes(cusEnt.id), ); @@ -129,7 +143,8 @@ export const cusProductsToCusEnts = ({ if (notNullish(customerEntitlementFilters?.interval)) { cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.interval === customerEntitlementFilters.interval, + (cusEnt) => + cusEnt.entitlement.interval === customerEntitlementFilters.interval, ); }