From ec1e98b580ab1679c77fb19401bd6db592ba3741 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 4 Jun 2026 19:18:17 +0100 Subject: [PATCH] chore: discount line items directly --- .../computeAllocatedInvoiceLineItems.ts | 30 +- .../setupAllocatedInvoiceContext.ts | 9 + .../attach/setup/setupAttachBillingContext.ts | 15 + ...etupImmediateMultiProductBillingContext.ts | 14 + .../computeUpdateQuantityLineItems.ts | 28 +- .../setupUpdateSubscriptionBillingContext.ts | 12 + .../buildAutumnLineItems.ts | 4 +- .../buildSharedSubscriptionTrialLineItems.ts | 6 +- .../setup/fetchStoredLineItemsForBilling.ts | 53 +++ ...chStoredLineItemsForSubscriptionBilling.ts | 27 ++ .../v2/setup/getSiblingCusProductIds.ts | 26 ++ .../billingPlanToNextCycleLineItems.ts | 4 +- .../lineItems/chargeRowToRefundLineItem.ts | 95 +++++ .../lineItems/getLineItemsForDirection.ts | 39 ++ .../lineItems/getRefundLineItemForPrice.ts | 29 ++ .../v2/utils/lineItems/getRefundLineItems.ts | 38 ++ .../invoiceCreditFromStoredLineItems.ts | 120 ++++++ .../v2/utils/lineItems/storedLineItemUtils.ts | 81 ++++ .../repos/getByCustomerProductAndPeriod.ts | 43 +++ .../invoices/lineItems/repos/index.ts | 2 + .../additional-coverage.test.ts | 290 ++++++++++++++ .../invoice-matched-credits/cancel.test.ts | 279 ++++++++++++++ .../create-schedule.test.ts | 99 +++++ .../discount-coverage.test.ts | 282 ++++++++++++++ .../downgrades.test.ts | 171 +++++++++ .../edge-cases.test.ts | 281 ++++++++++++++ .../invoice-matched-credits/entities.test.ts | 203 ++++++++++ .../multi-attach.test.ts | 157 ++++++++ .../invoice-matched-credits/quantity.test.ts | 85 +++++ .../invoice-matched-credits/trials.test.ts | 252 +++++++++++++ .../invoice-matched-credits/upgrades.test.ts | 355 ++++++++++++++++++ .../invoice-credit-matcher.spec.ts | 201 ++++++++++ .../billingModels/context/billingContext.ts | 4 + shared/utils/billingUtils/index.ts | 1 + .../invoicingUtils/billingConstants.ts | 1 + .../filterUnchangedPricesFromLineItems.ts | 6 +- 36 files changed, 3315 insertions(+), 27 deletions(-) create mode 100644 server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts create mode 100644 server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts create mode 100644 server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts create mode 100644 server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/cancel.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/entities.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/quantity.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/trials.test.ts create mode 100644 server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts create mode 100644 server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/billingConstants.ts diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts index bd331e2d3..55c540276 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts @@ -1,6 +1,8 @@ import { + BILLING_AMOUNT_EPSILON, cusEntToCusPrice, InternalError, + type LineItem, type LineItemContext, orgToCurrency, priceToProrationConfig, @@ -10,6 +12,7 @@ import { import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod"; +import { getRefundLineItemForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice"; import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext"; import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade"; @@ -66,7 +69,7 @@ export const computeAllocatedInvoiceLineItems = ({ customerProduct, }; - const previousLIneItem = usagePriceToLineItem({ + const catalogRefundLineItem = usagePriceToLineItem({ cusEnt: previousCustomerEntitlement, context: { ...lineItemContext, @@ -78,6 +81,14 @@ export const computeAllocatedInvoiceLineItems = ({ }, }); + const previousLineItem = getRefundLineItemForPrice({ + ctx, + customerProduct, + billingContext, + priceId: customerPrice.price.id, + catalogFallback: catalogRefundLineItem, + }); + const newLineItem = usagePriceToLineItem({ cusEnt: billingContext.updatedCustomerEntitlement, context: lineItemContext, @@ -87,15 +98,16 @@ export const computeAllocatedInvoiceLineItems = ({ }, }); - // Don't return line items if they sum to 0 - if ( + const netAmount = Math.abs( sumValues([ - previousLIneItem?.amountAfterDiscounts ?? 0, + previousLineItem?.amountAfterDiscounts ?? 0, newLineItem?.amountAfterDiscounts ?? 0, - ]) === 0 - ) { - return []; - } + ]), + ); - return [previousLIneItem, newLineItem]; + if (netAmount < BILLING_AMOUNT_EPSILON) return []; + + return [previousLineItem, newLineItem].filter( + (li): li is LineItem => li !== undefined, + ); }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts index 70d92d83b..dfd562ab5 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts @@ -10,6 +10,7 @@ import { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.js"; +import { fetchStoredLineItemsForBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForBilling.js"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext.js"; import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js"; import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js"; @@ -108,6 +109,12 @@ export const setupAllocatedInvoiceContext = async ({ cusEnt: newCustomerEntitlement, }); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForBilling({ + db: ctx.db, + customerProductIds: [cusProduct.id], + }); + return { // BillingContext fields fullCustomer, @@ -120,6 +127,8 @@ export const setupAllocatedInvoiceContext = async ({ stripeSubscription, stripeSubscriptionSchedule, stripeDiscounts, + storedChargeLineItems, + storedRefundLineItems, paymentMethod, billingVersion: BillingVersion.V2, diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index adb77132f..835ccf7ba 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -26,6 +26,7 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor"; import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities"; import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund"; import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior"; @@ -261,6 +262,17 @@ export const setupAttachBillingContext = async ({ contextOverride, }); + const outgoingCusProductIds = currentCustomerProduct + ? [currentCustomerProduct.id] + : []; + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, + }); + return { fullCustomer, fullProducts: [attachProduct], @@ -321,6 +333,9 @@ export const setupAttachBillingContext = async ({ skipBillingChanges, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, + anchorResetRefund: setupAnchorResetRefund({ billingCycleAnchor: params.billing_cycle_anchor, prorationBehavior: params.proration_behavior, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 5277e7d89..b2102ab76 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -15,6 +15,7 @@ import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/ import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; @@ -226,6 +227,17 @@ export const setupImmediateMultiProductBillingContext = async ({ (productContext) => productContext.customEnts, ); + const outgoingCusProductIds = productContexts + .map((pc) => pc.currentCustomerProduct?.id) + .filter((id): id is string => id != null); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, + }); + return { fullCustomer, fullProducts, @@ -265,5 +277,7 @@ export const setupImmediateMultiProductBillingContext = async ({ params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }), checkoutSessionParams: params.checkout_session_params, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, }; }; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts index 7024f0c88..dfde28205 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts @@ -1,5 +1,6 @@ import type { BillingContext } from "@autumn/shared"; import { + BILLING_AMOUNT_EPSILON, type BillingPeriod, cloneEntitlementWithUpdatedQuantity, cusEntToCusPrice, @@ -8,6 +9,7 @@ import { type FullCusProduct, findPrepaidCustomerEntitlement, InternalError, + type LineItem, type LineItemContext, orgToCurrency, priceToProrationConfig, @@ -15,6 +17,7 @@ import { usagePriceToLineItem, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getRefundLineItemForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice"; export const computeUpdateQuantityLineItems = ({ ctx, @@ -88,7 +91,7 @@ export const computeUpdateQuantityLineItems = ({ customerProduct, }; - const refundLineItem = usagePriceToLineItem({ + const catalogRefundLineItem = usagePriceToLineItem({ cusEnt: prepaidCustomerEntitlement, context: { ...lineItemContext, @@ -100,6 +103,14 @@ export const computeUpdateQuantityLineItems = ({ }, }); + const refundLineItem = getRefundLineItemForPrice({ + ctx, + customerProduct, + billingContext, + priceId: customerPrice.price.id, + catalogFallback: catalogRefundLineItem, + }); + const chargeLineItem = usagePriceToLineItem({ cusEnt: newCustomerEntitlement, context: lineItemContext, @@ -109,15 +120,16 @@ export const computeUpdateQuantityLineItems = ({ }, }); - // Don't return line items if they sum to 0 - if ( + const netAmount = Math.abs( sumValues([ refundLineItem?.amountAfterDiscounts ?? 0, chargeLineItem?.amountAfterDiscounts ?? 0, - ]) === 0 - ) { - return []; - } + ]), + ); - return [refundLineItem, chargeLineItem]; + if (netAmount < BILLING_AMOUNT_EPSILON) return []; + + return [refundLineItem, chargeLineItem].filter( + (li): li is LineItem => li !== undefined, + ); }; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 72eaf361f..5bb510c40 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -19,6 +19,7 @@ import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullC import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupAttachCheckoutMode } from "../../attach/setup/setupAttachCheckoutMode"; import { setupUpdateSubscriptionIntent } from "./setupUpdateSubscriptionIntent"; import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext"; @@ -176,6 +177,14 @@ export const setupUpdateSubscriptionBillingContext = async ({ customerProduct, }); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds: [customerProduct.id], + }); + return { intent, fullCustomer, @@ -217,6 +226,9 @@ export const setupUpdateSubscriptionBillingContext = async ({ skipBillingChanges, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, + checkoutMode, anchorResetRefund: setupAnchorResetRefund({ diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 44a586912..c698ca2cb 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -5,6 +5,7 @@ import type { UpdateCustomerEntitlement, } from "@autumn/shared"; import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems"; +import { getRefundLineItems } from "@/internal/billing/v2/utils/lineItems/getRefundLineItems"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems"; import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems"; @@ -55,11 +56,10 @@ export const buildAutumnLineItems = ({ // Get line items for ongoing cus product const deletedLineItems = customerProductsToDelete.flatMap((customerProduct) => - customerProductToLineItems({ + getRefundLineItems({ ctx, customerProduct, billingContext, - direction: "refund", priceFilters: { excludeOneOffPrices: true }, }), ); diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index f93ba10af..a8f9f3a9d 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -13,7 +13,7 @@ import { getDeleteCustomerProducts, getUpdateCustomerProducts, } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; -import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; +import { getLineItemsForDirection } from "@/internal/billing/v2/utils/lineItems/getLineItemsForDirection"; const formatLineItem = (item: LineItem) => ({ description: item.description, @@ -141,9 +141,9 @@ export const buildSharedSubscriptionTrialLineItems = ({ const lineItems: LineItem[] = []; for (const customerProduct of siblingCustomerProducts) { lineItems.push( - ...customerProductToLineItems({ + ...getLineItemsForDirection({ ctx, - customerProduct: customerProduct, + customerProduct, billingContext, direction, priceFilters: { excludeOneOffPrices: true }, diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts new file mode 100644 index 000000000..cf27a5fbb --- /dev/null +++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts @@ -0,0 +1,53 @@ +import type { DbInvoiceLineItem } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos"; + +const deduplicateById = (rows: DbInvoiceLineItem[]): DbInvoiceLineItem[] => { + const seen = new Set(); + return rows.filter((row) => { + if (seen.has(row.id)) return false; + seen.add(row.id); + return true; + }); +}; + +export const fetchStoredLineItemsForBilling = async ({ + db, + customerProductIds, +}: { + db: DrizzleCli; + customerProductIds: string[]; +}): Promise<{ + storedChargeLineItems: DbInvoiceLineItem[]; + storedRefundLineItems: DbInvoiceLineItem[]; +}> => { + if (customerProductIds.length === 0) { + return { storedChargeLineItems: [], storedRefundLineItems: [] }; + } + + const [chargeResults, refundResults] = await Promise.all([ + Promise.all( + customerProductIds.map((cusProductId) => + invoiceLineItemRepo.getByCustomerProductAndPeriod({ + db, + customerProductId: cusProductId, + direction: "charge", + }), + ), + ), + Promise.all( + customerProductIds.map((cusProductId) => + invoiceLineItemRepo.getByCustomerProductAndPeriod({ + db, + customerProductId: cusProductId, + direction: "refund", + }), + ), + ), + ]); + + return { + storedChargeLineItems: deduplicateById(chargeResults.flat()), + storedRefundLineItems: deduplicateById(refundResults.flat()), + }; +}; diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts new file mode 100644 index 000000000..2ee3e06aa --- /dev/null +++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts @@ -0,0 +1,27 @@ +import type { FullCustomer } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { fetchStoredLineItemsForBilling } from "./fetchStoredLineItemsForBilling"; +import { getSiblingCusProductIds } from "./getSiblingCusProductIds"; + +export const fetchStoredLineItemsForSubscriptionBilling = async ({ + db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, +}: { + db: DrizzleCli; + fullCustomer: FullCustomer; + stripeSubscription?: Stripe.Subscription; + outgoingCusProductIds: string[]; +}) => { + const siblingIds = getSiblingCusProductIds({ + fullCustomer, + stripeSubscription, + excludeIds: outgoingCusProductIds, + }); + return fetchStoredLineItemsForBilling({ + db, + customerProductIds: [...outgoingCusProductIds, ...siblingIds], + }); +}; diff --git a/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts new file mode 100644 index 000000000..b6daf5906 --- /dev/null +++ b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts @@ -0,0 +1,26 @@ +import { cp, type FullCustomer } from "@autumn/shared"; +import type Stripe from "stripe"; + +export const getSiblingCusProductIds = ({ + fullCustomer, + stripeSubscription, + excludeIds = [], +}: { + fullCustomer: FullCustomer; + stripeSubscription?: Stripe.Subscription; + excludeIds?: string[]; +}): string[] => { + if (!stripeSubscription) return []; + + const excluded = new Set(excludeIds); + + return fullCustomer.customer_products + .filter( + (cusProduct) => + !excluded.has(cusProduct.id) && + cp(cusProduct).paid().recurring().onStripeSubscription({ + stripeSubscriptionId: stripeSubscription.id, + }).valid, + ) + .map((cusProduct) => cusProduct.id); +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts index 7f7127e69..1ab8c3883 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts @@ -11,7 +11,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; import { filterStripeDiscountsForNextCycle } from "@/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle"; import { customerProductToArrearLineItems } from "../../lineItems/customerProductToArrearLineItems"; -import { customerProductToLineItems } from "../../lineItems/customerProductToLineItems"; +import { getLineItemsForDirection } from "../../lineItems/getLineItemsForDirection"; import { lineItemToPreviewLineItem } from "../../lineItems/lineItemToPreviewLineItem"; import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPreviewUsageLineItem"; @@ -38,7 +38,7 @@ const buildLineItemsForSpec = ({ nextCycleStart: number; }) => { const lineItems = spec.customerProducts.flatMap((customerProduct) => - customerProductToLineItems({ + getLineItemsForDirection({ ctx, customerProduct, billingContext: { diff --git a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts new file mode 100644 index 000000000..20a29e4aa --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts @@ -0,0 +1,95 @@ +import { generateKsuid } from "@autumn/ksuid"; +import type { BillingContext } from "@autumn/shared"; +import { + customerProductToEntity, + type DbInvoiceLineItem, + type FullCusProduct, + type InvoiceLineItemDiscount, + type LineItem, + type LineItemContext, + LineItemSchema, + orgToCurrency, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const chargeRowToRefundLineItem = ({ + chargeRow, + creditAmount, + customerProduct, + billingContext, + ctx, +}: { + chargeRow: DbInvoiceLineItem; + creditAmount: number; + customerProduct: FullCusProduct; + billingContext: BillingContext; + ctx: AutumnContext; +}): LineItem => { + const periodStart = + chargeRow.effective_period_start ?? billingContext.currentEpochMs; + const periodEnd = + chargeRow.effective_period_end ?? billingContext.currentEpochMs; + + const entity = customerProductToEntity({ + customerProduct, + entities: billingContext.fullCustomer.entities, + }); + + const matchingCusPrice = customerProduct.customer_prices.find( + (cp) => cp.price.id === chargeRow.price_id, + ); + const price = + matchingCusPrice?.price ?? customerProduct.customer_prices[0]?.price; + + if (!price) { + throw new Error( + `[chargeRowToRefundLineItem] No price found on cusProduct ${customerProduct.id} for charge row ${chargeRow.id}`, + ); + } + + const context: LineItemContext = { + price, + product: customerProduct.product, + feature: undefined, + currency: orgToCurrency({ org: ctx.org }), + billingPeriod: { start: periodStart, end: periodEnd }, + effectivePeriod: { start: billingContext.currentEpochMs, end: periodEnd }, + direction: "refund", + now: billingContext.currentEpochMs, + billingTiming: "in_advance", + discountable: false, + entity, + customerProduct, + customerPrice: matchingCusPrice, + }; + + const description = chargeRow.description + ? `Unused ${chargeRow.description}` + : `Unused ${customerProduct.product.name}`; + + const lineItemData = { + id: generateKsuid({ prefix: "invoice_li_" }), + amount: creditAmount, + amountAfterDiscounts: creditAmount, + description, + context, + stripePriceId: chargeRow.stripe_price_id ?? undefined, + stripeProductId: chargeRow.stripe_product_id ?? undefined, + chargeImmediately: true, + prorated: true, + discounts: + (chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({ + amountOff: d.amount_off, + percentOff: d.percent_off, + stripeCouponId: d.stripe_coupon_id, + couponName: d.stripe_coupon_id, + })) ?? [], + }; + + const result = LineItemSchema.safeParse(lineItemData); + if (!result.success) { + throw result.error; + } + + return result.data; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts new file mode 100644 index 000000000..4938db98d --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts @@ -0,0 +1,39 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerProductToLineItems } from "./customerProductToLineItems"; +import { getRefundLineItems } from "./getRefundLineItems"; + +export const getLineItemsForDirection = ({ + ctx, + customerProduct, + billingContext, + direction, + priceFilters, + billingCycleAnchorMsOverride, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + direction: "charge" | "refund"; + priceFilters?: { excludeOneOffPrices?: boolean }; + billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; +}): LineItem[] => { + if (direction === "refund") { + return getRefundLineItems({ + ctx, + customerProduct, + billingContext, + priceFilters, + billingCycleAnchorMsOverride, + }); + } + + return customerProductToLineItems({ + ctx, + customerProduct, + billingContext, + direction, + priceFilters, + billingCycleAnchorMsOverride, + }); +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice.ts new file mode 100644 index 000000000..8129c3d37 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice.ts @@ -0,0 +1,29 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getRefundLineItems } from "./getRefundLineItems"; + +export const getRefundLineItemForPrice = ({ + ctx, + customerProduct, + billingContext, + priceId, + catalogFallback, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + priceId: string; + catalogFallback: LineItem | undefined; +}): LineItem | undefined => { + const matchedRefundLineItems = getRefundLineItems({ + ctx, + customerProduct, + billingContext, + }); + + const matchedRefundForPrice = matchedRefundLineItems.find( + (li) => li.context.price.id === priceId, + ); + + return matchedRefundForPrice ?? catalogFallback; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts new file mode 100644 index 000000000..7459c73d8 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts @@ -0,0 +1,38 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerProductToLineItems } from "./customerProductToLineItems"; +import { invoiceCreditFromStoredLineItems } from "./invoiceCreditFromStoredLineItems"; + +export const getRefundLineItems = ({ + ctx, + customerProduct, + billingContext, + priceFilters, + billingCycleAnchorMsOverride, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + priceFilters?: { excludeOneOffPrices?: boolean }; + billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; +}): LineItem[] => { + const { lineItems: matchedCredits, allPricesResolved } = + invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + if (allPricesResolved) return matchedCredits; + + const catalogCredits = customerProductToLineItems({ + ctx, + customerProduct, + billingContext, + direction: "refund", + priceFilters, + billingCycleAnchorMsOverride, + }); + + return [...matchedCredits, ...catalogCredits]; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts new file mode 100644 index 000000000..6037a4175 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts @@ -0,0 +1,120 @@ +import type { BillingContext } from "@autumn/shared"; +import { + type FullCusProduct, + isOneOffPrice, + type LineItem, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem"; +import { + computeAlreadyRefundedForCharge, + computeProratedCredit, + splitMultiEntityAmount, +} from "./storedLineItemUtils"; + +type InvoiceMatchedCreditResult = { + lineItems: LineItem[]; + allPricesResolved: boolean; +}; + +export const invoiceCreditFromStoredLineItems = ({ + ctx, + customerProduct, + billingContext, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; +}): InvoiceMatchedCreditResult => { + const { logger } = ctx; + const now = billingContext.currentEpochMs; + const chargeRows = billingContext.storedChargeLineItems ?? []; + const refundRows = billingContext.storedRefundLineItems ?? []; + + const pricesToCredit = customerProduct.customer_prices.filter( + (cp) => !isOneOffPrice(cp.price), + ); + + if (pricesToCredit.length === 0) { + return { lineItems: [], allPricesResolved: true }; + } + + const allLineItems: LineItem[] = []; + let anyMissed = false; + + for (const cusPrice of pricesToCredit) { + const priceChargeRows = chargeRows.filter( + (row) => + row.customer_product_ids.includes(customerProduct.id) && + (row.price_id === cusPrice.price.id || + row.stripe_price_id === cusPrice.price.config?.stripe_price_id), + ); + + const usableRows = priceChargeRows.filter( + (row) => + row.customer_product_ids.length > 0 && + row.effective_period_start != null && + row.effective_period_end != null && + row.effective_period_start < now && + row.effective_period_end > now, + ); + + if (usableRows.length === 0) { + anyMissed = true; + logger.warn( + `[invoiceCreditFromStoredLineItems] No usable stored charge row for cusProduct=${customerProduct.id} price=${cusPrice.price.id}; falling back to catalog synthesis`, + ); + continue; + } + + const currentPeriodRefunds = refundRows.filter( + (r) => + r.customer_product_ids.includes(customerProduct.id) && + r.effective_period_end != null && + r.effective_period_start != null && + r.effective_period_start < now && + r.effective_period_end > now, + ); + + for (const chargeRow of usableRows) { + const attributedAmount = splitMultiEntityAmount(chargeRow); + + const alreadyRefunded = computeAlreadyRefundedForCharge({ + chargeRow, + refundRows: currentPeriodRefunds, + }); + + const adjustedChargeRow = { + ...chargeRow, + amount_after_discounts: attributedAmount, + }; + + const creditAmount = computeProratedCredit({ + chargeRow: adjustedChargeRow, + now, + alreadyRefunded, + }); + + if (creditAmount === 0) continue; + + allLineItems.push( + chargeRowToRefundLineItem({ + chargeRow, + creditAmount, + customerProduct, + billingContext, + ctx, + }), + ); + } + } + + if (anyMissed && allLineItems.length === 0) { + return { lineItems: [], allPricesResolved: false }; + } + + return { + lineItems: allLineItems, + allPricesResolved: !anyMissed, + }; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts new file mode 100644 index 000000000..be302ee4f --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts @@ -0,0 +1,81 @@ +import type { DbInvoiceLineItem } from "@autumn/shared"; +import { Decimal } from "decimal.js"; + +export const isWithinPeriod = ( + inner: DbInvoiceLineItem, + outer: DbInvoiceLineItem, +): boolean => + inner.effective_period_start != null && + outer.effective_period_start != null && + inner.effective_period_end != null && + outer.effective_period_end != null && + inner.effective_period_start >= outer.effective_period_start && + inner.effective_period_end <= outer.effective_period_end; + +export const hasSamePrice = ( + a: DbInvoiceLineItem, + b: DbInvoiceLineItem, +): boolean => + (a.price_id != null && a.price_id === b.price_id) || + (a.stripe_price_id != null && a.stripe_price_id === b.stripe_price_id); + +export const computeProratedCredit = ({ + chargeRow, + now, + alreadyRefunded, +}: { + chargeRow: DbInvoiceLineItem; + now: number; + alreadyRefunded: number; +}): number => { + const periodStart = chargeRow.effective_period_start; + const periodEnd = chargeRow.effective_period_end; + + if (periodStart == null || periodEnd == null || periodEnd <= periodStart) { + return 0; + } + + const totalCharged = chargeRow.amount_after_discounts; + const refundable = new Decimal(totalCharged).minus(alreadyRefunded); + + if (refundable.lte(0)) return 0; + + const remaining = new Decimal(periodEnd).minus(now); + const total = new Decimal(periodEnd).minus(periodStart); + + if (remaining.lte(0)) return 0; + + const prorationFraction = remaining.div(total); + return prorationFraction.mul(refundable).neg().toNumber(); +}; + +export const computeAlreadyRefundedForCharge = ({ + chargeRow, + refundRows, +}: { + chargeRow: DbInvoiceLineItem; + refundRows: DbInvoiceLineItem[]; +}): number => { + const matchingRefunds = refundRows.filter( + (refund) => + isWithinPeriod(refund, chargeRow) && hasSamePrice(refund, chargeRow), + ); + + return matchingRefunds.reduce( + (sum, r) => + new Decimal(sum) + .plus(Math.abs(splitMultiEntityAmount(r))) + .toNumber(), + 0, + ); +}; + +export const splitMultiEntityAmount = ( + chargeRow: DbInvoiceLineItem, +): number => { + const ids = chargeRow.customer_product_ids; + if (ids.length <= 1) return chargeRow.amount_after_discounts; + return new Decimal(chargeRow.amount_after_discounts) + .div(ids.length) + .toNumber(); +}; diff --git a/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts new file mode 100644 index 000000000..d28bedbef --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts @@ -0,0 +1,43 @@ +import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared"; +import { and, eq, gte, lte, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; + +export const getByCustomerProductAndPeriod = async ({ + db, + customerProductId, + direction, + priceId, + periodStartMs, + periodEndMs, +}: { + db: DrizzleCli; + customerProductId: string; + direction: "charge" | "refund"; + priceId?: string; + periodStartMs?: number; + periodEndMs?: number; +}): Promise => { + const conditions = [ + eq(invoiceLineItems.direction, direction), + sql`${invoiceLineItems.customer_product_ids}::jsonb @> ${JSON.stringify([customerProductId])}::jsonb`, + ]; + + if (priceId) { + conditions.push(eq(invoiceLineItems.price_id, priceId)); + } + + if (periodStartMs !== undefined) { + conditions.push( + lte(invoiceLineItems.effective_period_start, periodStartMs), + ); + } + + if (periodEndMs !== undefined) { + conditions.push(gte(invoiceLineItems.effective_period_end, periodEndMs)); + } + + return db + .select() + .from(invoiceLineItems) + .where(and(...conditions)); +}; diff --git a/server/src/internal/invoices/lineItems/repos/index.ts b/server/src/internal/invoices/lineItems/repos/index.ts index 58bcbe5d0..da4f73218 100644 --- a/server/src/internal/invoices/lineItems/repos/index.ts +++ b/server/src/internal/invoices/lineItems/repos/index.ts @@ -1,5 +1,6 @@ import { deleteByInvoiceId } from "./deleteByInvoiceId"; import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId"; +import { getByCustomerProductAndPeriod } from "./getByCustomerProductAndPeriod"; import { getByInvoiceId } from "./getByInvoiceId"; import { getByInvoiceIds } from "./getByInvoiceIds"; import { getByStripeInvoiceId } from "./getByStripeInvoiceId"; @@ -18,6 +19,7 @@ export const invoiceLineItemRepo = { getByInvoiceId, getByInvoiceIds, getByStripeInvoiceId, + getByCustomerProductAndPeriod, deleteByInvoiceId, deleteStaleByStripeInvoiceId, getDeferredByInvoiceItemIds, diff --git a/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts new file mode 100644 index 000000000..54c7b08a2 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts @@ -0,0 +1,290 @@ +/** + * Invoice-Matched Proration Credits — Additional Coverage + * + * 1. amount-off coupon cancel: refund based on discounted invoice + * 2. cancel after partial refund (upgrade then cancel): second refund nets the first + * 3. create-schedule with discount: immediate phase credit from stored charge + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, AttachPreviewResponse } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createAmountCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Amount-off coupon cancel — refund based on discounted invoice ($15) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 1: amount-off coupon cancel — refund based on discounted invoice")}`, + async () => { + const customerId = "imc-add-amtoff-cancel"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 15, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 15, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + expect(Math.abs(preview.total)).toBeLessThan(10); + expect(Math.abs(preview.total)).toBeGreaterThan(5); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 3, + latestTotal: preview.total, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Cancel after partial refund (upgrade then cancel) — second refund nets the first +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 2: cancel after upgrade — second refund nets the first")}`, + async () => { + const customerId = "imc-add-upg-then-cancel"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const upgradeResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(upgradeResult.invoice).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterUpgrade = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUpgrade, + productId: `premium_${customerId}`, + }); + + const cancelParams = { + customer_id: customerId, + product_id: `premium_${customerId}`, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: `premium_${customerId}`, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Scheduled upgrade with discount — immediate phase credit from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 3: scheduled upgrade with discount — credit reflects discounted charge")}`, + async () => { + const customerId = "imc-add-sched-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 400, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 16, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16); + + for (const creditLine of creditLines) { + const discounts = creditLine.discounts ?? []; + expect(discounts.length).toBe(0); + } + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts new file mode 100644 index 000000000..8741c18c9 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts @@ -0,0 +1,279 @@ +/** + * Invoice-Matched Proration Credits — Cancel Tests + * + * Verifies that cancellation credits and refunds source amounts from stored + * invoice line items rather than catalog prices. + * + * - cancel_immediately with discount: credit reflects discounted charge ($16) + * - cancel_end_of_cycle: no immediate credit line items + * - refund_last_payment prorated with discount: refund based on discounted invoice + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Cancel immediately prorated with discount — credit from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 1: cancel immediately with discount — credit reflects stored charge")}`, + async () => { + const customerId = "imc-cancel-imm-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 16, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + expect(preview.total).toBeGreaterThan(-16); + expect(preview.total).toBeLessThanOrEqual(-7); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 3, + latestTotal: preview.total, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Cancel end_of_cycle — no immediate credit lines +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 2: cancel end_of_cycle — no immediate credit line items")}`, + async () => { + const customerId = "imc-cancel-eoc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const customerBeforeCancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBeforeCancel, + productId: pro.id, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBe(0); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBe(0); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 1, + latestTotal: 20, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Cancel discounted plan refund_last_payment prorated — refund based on discounted invoice +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 3: cancel with refund_last_payment prorated — refund reflects discounted invoice")}`, + async () => { + const customerId = "imc-cancel-refund-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + refund_last_payment: "prorated" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBe(0); + expect(preview.refund).toBeDefined(); + + const refundAmount = preview.refund!.amount; + expect(refundAmount).toBeGreaterThan(0); + expect(refundAmount).toBeLessThanOrEqual(16); + expect(refundAmount).toBeGreaterThanOrEqual(7); + + expect(preview.refund!.invoice.total).toBe(16); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + }); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts new file mode 100644 index 000000000..d1f3a3900 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits create-schedule: pro + addon credits reflect stored discounted charges")}`, + async () => { + const customerId = "imc-sched-disc-addon"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, addon, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `addon_${customerId}`, + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(Math.abs(creditTotal)).toBeLessThan(20 + 10); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts new file mode 100644 index 000000000..0cc9b1352 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts @@ -0,0 +1,282 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 1: catalog fallback when no stored row exists")}`, + async () => { + const customerId = "imc-disc-fallback"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + expect(preview.line_items.length).toBeGreaterThan(0); + expect(preview.total).toBeDefined(); + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThanOrEqual(-20); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 2: discounted quantity decrease — refund based on stored discounted charge")}`, + async () => { + const customerId = "imc-disc-qty-dec"; + + const billingUnits = 100; + const pricePerPack = 10; + + const prepaidMessages = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: pricePerPack, + }); + + const product = products.pro({ + id: "prepaid-disc", + items: [prepaidMessages], + }); + + const initialQuantity = 500; + const decreasedQuantity = 200; + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `prepaid-disc_${customerId}`, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: `prepaid-disc_${customerId}`, + options: [ + { + feature_id: TestFeature.Messages, + quantity: decreasedQuantity, + }, + ], + }); + + expect(preview.total).toBeLessThan(0); + + const fullPriceRefundBound = + -((initialQuantity - decreasedQuantity) / billingUnits) * pricePerPack; + expect(preview.total).toBeGreaterThan(fullPriceRefundBound); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 3: trial sibling with discount — credit reflects discounted charge")}`, + async () => { + const customerId = "imc-disc-trial-sib"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [items.monthlyMessages({ includedUsage: 1000 })], + trialDays: 14, + cardRequired: false, + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium-trial_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `premium-trial_${customerId}`, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 4: discounted credit magnitude bounded by stored charge")}`, + async () => { + const customerId = "imc-disc-no-double"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16.01); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts new file mode 100644 index 000000000..0f22785ef --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts @@ -0,0 +1,171 @@ +/** + * Invoice-Matched Proration Credits — Downgrade Tests + * + * Verifies that scheduled downgrade previews source outgoing credits from + * stored invoice line items (actual charged amounts) rather than catalog prices. + * + * - With discount: outgoing credit reflects the discounted charge ($40, not $50) + * - Without discount: outgoing credit reflects the full catalog charge ($50) + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Scheduled downgrade with discount — outgoing credit reflects discounted price +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits downgrade 1: scheduled downgrade with discount — next_cycle outgoing credit reflects discounted price")}`, + async () => { + const customerId = "imc-down-disc"; + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 40, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 40, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 5, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + }); + + expect(preview.total).toBe(0); + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + expectDefined: true, + })!; + + expect(nextCycle.total).toBeLessThan(50); + expect(nextCycle.total).toBeGreaterThan(0); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Scheduled downgrade without discount — outgoing credit reflects full price +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits downgrade 2: scheduled downgrade without discount — next_cycle outgoing credit reflects full price")}`, + async () => { + const customerId = "imc-down-full"; + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.advanceToNextInvoice(), + s.advanceTestClock({ days: 5 }), + ], + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 50, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + }); + + expect(preview.total).toBe(0); + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + expectDefined: true, + })!; + + expect(nextCycle.total).toBeLessThan(50); + expect(nextCycle.total).toBeGreaterThan(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts new file mode 100644 index 000000000..2df63e125 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts @@ -0,0 +1,281 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Catalog fallback when no stored row exists +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 1: catalog fallback when no stored row exists")}`, + async () => { + const customerId = "inv-match-edge-fallback"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const upgradeResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(upgradeResult).toBeDefined(); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Multi-attach with outgoing credit +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 2: multi-attach with outgoing credit from stored charge")}`, + async () => { + const customerId = "inv-match-edge-multi"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.billing.previewMultiAttach({ + customer_id: customerId, + plans: [{ plan_id: premium.id }, { plan_id: addon.id }], + }); + + expect(preview.total).toBeDefined(); + expect(preview.outgoing.length).toBeGreaterThanOrEqual(1); + + const outgoingPro = preview.outgoing.find((c: { plan_id: string }) => c.plan_id === pro.id); + expect(outgoingPro).toBeDefined(); + + await autumnV1.billing.multiAttach({ + customer_id: customerId, + plans: [{ plan_id: premium.id }, { plan_id: addon.id }], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id, addon.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 200, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: invoiceCountBefore + 1, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeDefined(); + + expect(latestInvoice!.total).toBeLessThan(70); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: No-op re-attach — filterUnchangedPrices cancels +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 3: no-op re-attach — filterUnchangedPrices cancels")}`, + async () => { + const customerId = "inv-match-edge-noop"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + ], + }); + + await expectProductActive({ + customer: await autumnV1.customers.get(customerId), + productId: pro.id, + }); + + let threw = false; + try { + await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + } catch (err: any) { + threw = true; + expect(err.code).toBe("plan_already_attached"); + } + expect(threw).toBe(true); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Non-USD currency rounding (EUR) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 4: non-USD currency — EUR upgrade rounding")}`, + async () => { + const customerId = "inv-match-edge-eur"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(preview.total).toBeDefined(); + expect(preview.total).toBeGreaterThan(0); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: invoiceCountBefore + 1, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeCloseTo(preview.total, 0); + + const diff = Math.abs(latestInvoice!.total - preview.total); + expect(diff).toBeLessThanOrEqual(0.01); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/entities.test.ts b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts new file mode 100644 index 000000000..7f8d7a501 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 1: single entity upgrade — credit from stored charge")}`, + async () => { + const customerId = "imc-ent-single-upg"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + ], + }); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + entity_id: entities[0].id, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + expect(preview.total).toBeDefined(); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 2: entity with discount — credit reflects discounted amount")}`, + async () => { + const customerId = "imc-ent-disc-upg"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + entity_id: entities[0].id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + entity_id: entities[0].id, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16.01); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 3: entity add mid-cycle — no credit for new entity")}`, + async () => { + const customerId = "imc-ent-add-midcycle"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + ], + }); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + entity_id: entities[1].id, + }); + + const creditLines = (result.invoice?.line_items ?? []).filter( + (li: { total: number }) => li.total < 0, + ); + expect(creditLines.length).toBe(0); + + expect(result.invoice?.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts new file mode 100644 index 000000000..56c6c09e3 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits multi-attach 1: discounted outgoing — credit reflects stored charge")}`, + async () => { + const customerId = "imc-multi-disc-out"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = await autumnV1.billing.previewMultiAttach({ + customer_id: customerId, + plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }], + }); + + expect(preview.total).toBeDefined(); + expect(preview.outgoing.length).toBeGreaterThanOrEqual(1); + + const outgoingPro = preview.outgoing.find( + (c: { plan_id: string }) => c.plan_id === `pro_${customerId}`, + ); + expect(outgoingPro).toBeDefined(); + + const catalogTotal = 50 + 20; + expect(preview.total).toBeLessThan(catalogTotal); + + await autumnV1.billing.multiAttach({ + customer_id: customerId, + plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [`premium_${customerId}`, `addon_${customerId}`], + notPresent: [`pro_${customerId}`], + }); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits multi-attach 2: add-only — no credit lines")}`, + async () => { + const customerId = "imc-multi-add-only"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `addon_${customerId}`, + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: `pro_${customerId}`, + }); + + await expectProductActive({ + customer, + productId: `addon_${customerId}`, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts new file mode 100644 index 000000000..abc3ca8cf --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched qty 1: prepaid quantity decrease — credit from stored charge")}`, + async () => { + const customerId = "inv-match-qty-decrease"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: "pro" })], + }); + + let advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + numberOfMonths: 1, + waitForSeconds: 30, + }); + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + startingFrom: new Date(advancedTo), + numberOfDays: 15, + waitForSeconds: 20, + }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.products.length).toBeGreaterThan(0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched qty 2: prepaid quantity decrease then increase — netting")}`, + async () => { + const customerId = "inv-match-qty-netting"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: "pro" })], + }); + + let advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + numberOfMonths: 1, + waitForSeconds: 30, + }); + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + startingFrom: new Date(advancedTo), + numberOfDays: 15, + waitForSeconds: 20, + }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.products.length).toBeGreaterThan(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/trials.test.ts b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts new file mode 100644 index 000000000..dfcf81f32 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts @@ -0,0 +1,252 @@ +/** + * Invoice-Matched Proration Credits — Trial Tests + * + * Verifies correct credit behavior when trials interact with the + * invoice-matched credit system: + * + * - Upgrade during trial: no credit (no stored charge for a $0 trial) + * - Paid product switched to trial sibling: paid product credited from stored charge + * - End trial: no refund-direction line items emitted + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Upgrade during trial — no credit (trial product has no stored charge) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 1: upgrade during trial — no credit for outgoing trial product")}`, + async () => { + const customerId = "imc-trial-upgrade"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: false, + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premium] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const customerTrialing = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerTrialing, + active: [proTrial.id], + }); + + await expectCustomerInvoiceCorrect({ + customer: customerTrialing, + count: 1, + latestTotal: 0, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(preview.total).toBe(50); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBe(0); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customerAfterUpgrade = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterUpgrade, + active: [premium.id], + notPresent: [proTrial.id], + }); + + await expectProductNotTrialing({ + customer: customerAfterUpgrade, + productId: premium.id, + nowMs: advancedTo, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterUpgrade, + count: 2, + latestTotal: 50, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Paid product switched to trial sibling — sibling credited from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 2: paid product switched to trial — credit from stored charge")}`, + async () => { + const customerId = "imc-trial-sibling"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [items.monthlyMessages({ includedUsage: 1000 })], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 20, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium-trial_${customerId}`, + }); + + expect(preview.total).toBe(-20); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum: number, li: { total: number }) => sum + li.total, 0); + expect(creditTotal).toBe(-20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + const customerAfterSwitch = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterSwitch, + active: [premiumTrial.id], + notPresent: [pro.id], + }); + + await expectProductTrialing({ + customer: customerAfterSwitch, + productId: premiumTrial.id, + trialEndsAt: advancedTo + 14 * 24 * 60 * 60 * 1000, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: End trial — no refund lines emitted +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 3: end trial — no refund-direction line items")}`, + async () => { + const customerId = "imc-trial-end"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const customerTrialing = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerTrialing, + productId: proTrial.id, + trialEndsAt: advancedTo + 7 * 24 * 60 * 60 * 1000, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerTrialing, + count: 1, + latestTotal: 0, + }); + + const previewBeforeTrialEnd = await autumnV2_2.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: proTrial.id, + recalculate_balances: { enabled: true }, + }); + + const refundLines = previewBeforeTrialEnd.line_items.filter( + (li: { total: number }) => li.total < 0, + ); + expect(refundLines.length).toBe(0); + + const nextCyclePreview = expectPreviewNextCycleCorrect({ + preview: previewBeforeTrialEnd, + expectDefined: true, + })!; + + const nextCycleRefundLines = nextCyclePreview.line_items.filter( + (li) => li.total < 0, + ); + expect(nextCycleRefundLines.length).toBe(0); + + expect(nextCyclePreview.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts new file mode 100644 index 000000000..e7f645049 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts @@ -0,0 +1,355 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { + createAmountCoupon, + createPercentCoupon, +} from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 1: percent-off forever discount — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-pct"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeCloseTo(-8, 0); + + for (const creditLine of creditLines) { + const discounts = creditLine.discounts ?? []; + expect(discounts.length).toBe(0); + } + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 2: no discount — credit reflects full price")}`, + async () => { + const customerId = "inv-cred-upg-full"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 3: amount-off coupon — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-amt"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-15); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 4: at cycle start — full credit equals full charged amount")}`, + async () => { + const customerId = "inv-cred-upg-start"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20.01); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(Math.abs((result.invoice?.total ?? 0) - preview.total)).toBeLessThan( + 2, + ); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 5: upgrade twice in one period — second upgrade nets prior refund")}`, + async () => { + const customerId = "inv-cred-upg-twice"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const growth = products.growth({ + id: "growth", + items: [items.monthlyMessages({ includedUsage: 2000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium, growth] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const firstUpgradeResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(firstUpgradeResult.invoice).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const secondPreview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = secondPreview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const positiveLines = secondPreview.line_items.filter((li) => li.total > 0); + expect(positiveLines.length).toBeGreaterThan(0); + + const secondResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + }); + + expect(secondResult.invoice?.total).toBeCloseTo(secondPreview.total, 0); + }, + 300_000, +); diff --git a/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts new file mode 100644 index 000000000..f6d352f8f --- /dev/null +++ b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import type { DbInvoiceLineItem } from "@autumn/shared"; +import chalk from "chalk"; +import { + computeAlreadyRefundedForCharge, + computeProratedCredit, + splitMultiEntityAmount, +} from "@/internal/billing/v2/utils/lineItems/storedLineItemUtils"; + +const PERIOD_START = 1_700_000_000_000; +const PERIOD_END = PERIOD_START + 30 * 24 * 60 * 60 * 1000; +const MID_CYCLE = PERIOD_START + 15 * 24 * 60 * 60 * 1000; + +const makeChargeRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_charge_1", + amount: 20, + amount_after_discounts: 20, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "charge", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +const makeRefundRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_refund_1", + amount: -10, + amount_after_discounts: -10, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "refund", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +describe(chalk.yellowBright("computeProratedCredit"), () => { + test("prorates a full charge at mid-cycle to ~half negative", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBeLessThan(0); + expect(result).toBeCloseTo(-10, 0); + }); + + test("returns 0 when period has ended", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: PERIOD_END + 1000, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("returns 0 when period is null", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ effective_period_start: null }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("subtracts already-refunded before prorating", () => { + const fullCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const partialCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 10, + }); + + expect(Math.abs(partialCredit)).toBeLessThan(Math.abs(fullCredit)); + }); + + test("returns 0 when fully refunded", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 20, + }); + + expect(result).toBe(0); + }); + + test("uses amount_after_discounts (discounted charge gives smaller credit)", () => { + const fullPriceCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const discountedCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 16 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(Math.abs(discountedCredit)).toBeLessThan(Math.abs(fullPriceCredit)); + }); +}); + +describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => { + test("sums matching refund rows by price and period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ amount_after_discounts: -5 }), + makeRefundRow({ id: "li_refund_2", amount_after_discounts: -3 }), + ], + }); + + expect(result).toBe(8); + }); + + test("excludes refunds with different price_id", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ price_id: "price_other", stripe_price_id: "other" }), + ], + }); + + expect(result).toBe(0); + }); + + test("excludes refunds outside the charge period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ + effective_period_start: PERIOD_END + 1000, + effective_period_end: PERIOD_END + 30 * 24 * 60 * 60 * 1000, + }), + ], + }); + + expect(result).toBe(0); + }); + + test("returns 0 with no refund rows", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [], + }); + + expect(result).toBe(0); + }); +}); + +describe(chalk.yellowBright("splitMultiEntityAmount"), () => { + test("returns full amount for single cusProduct", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ amount_after_discounts: 30 }), + ); + + expect(result).toBe(30); + }); + + test("splits evenly across multiple cusProduct ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: ["cp_1", "cp_2", "cp_3"], + }), + ); + + expect(result).toBe(10); + }); + + test("handles empty customer_product_ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: [], + }), + ); + + expect(result).toBe(30); + }); +}); diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index ca0d56309..a1f62e64b 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -10,6 +10,7 @@ import type { } from "@autumn/shared"; import type { PaymentBehaviorIntent } from "@models/billingModels/context/paymentBehaviorIntent"; import type { TransitionConfig } from "@models/billingModels/context/transitionConfig"; +import type { DbInvoiceLineItem } from "@models/cusModels/invoiceModels/invoiceLineItemTable"; import type { EntInterval } from "@models/productModels/intervals/entitlementInterval"; import type Stripe from "stripe"; import { z } from "zod/v4"; @@ -112,6 +113,9 @@ export interface BillingContext { anchorResetRefund?: AnchorResetRefund; + storedChargeLineItems?: DbInvoiceLineItem[]; + storedRefundLineItems?: DbInvoiceLineItem[]; + refundLastPayment?: "prorated" | "full"; paymentBehaviorIntent?: PaymentBehaviorIntent; diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 09c2b2234..c9e856d0e 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -7,6 +7,7 @@ export * from "./intervalUtils/intervalArithmetic"; // Invoicing utils export * from "./invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.js"; +export * from "./invoicingUtils/billingConstants.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/billingConstants.ts b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts new file mode 100644 index 000000000..03a33a51c --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts @@ -0,0 +1 @@ +export const BILLING_AMOUNT_EPSILON = 0.01; diff --git a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts index 6ee8317b1..cb63d2bed 100644 --- a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts @@ -1,4 +1,5 @@ import type { LineItem } from "@models/billingModels/lineItem/lineItem"; +import { BILLING_AMOUNT_EPSILON } from "./billingConstants"; /** * Filters out line item pairs where a refund and charge item have the same price ID @@ -32,10 +33,9 @@ export const filterUnchangedPricesFromLineItems = ({ if (matchingChargeIndex !== -1) { const matchingChargeItem = chargeItems[matchingChargeIndex]; - const total = refundItem.amount + matchingChargeItem.amount; + const netAmount = Math.abs(refundItem.amount + matchingChargeItem.amount); - if (total === 0) { - // Amounts cancel out - mark charge item as matched (both will be removed) + if (netAmount < BILLING_AMOUNT_EPSILON) { matchedChargeIndices.add(matchingChargeIndex); continue; }