chore: discount line items directly

This commit is contained in:
Charlie Lamb
2026-06-04 19:18:17 +01:00
parent 701295abc8
commit ec1e98b580
36 changed files with 3315 additions and 27 deletions

View File

@@ -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,
);
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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,
);
};

View File

@@ -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({

View File

@@ -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 },
}),
);

View File

@@ -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 },

View File

@@ -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<string>();
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()),
};
};

View File

@@ -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],
});
};

View File

@@ -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);
};

View File

@@ -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: {

View File

@@ -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;
};

View File

@@ -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,
});
};

View File

@@ -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;
};

View File

@@ -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];
};

View File

@@ -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,
};
};

View File

@@ -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();
};

View File

@@ -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<DbInvoiceLineItem[]> => {
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));
};

View File

@@ -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,

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(customerId);
await expectProductNotPresent({
customer: customerAfterCancel,
productId: pro.id,
});
await expectCustomerInvoiceCorrect({
customer: customerAfterCancel,
count: 2,
});
},
300_000,
);

View File

@@ -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,
);

View File

@@ -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,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
);

View File

@@ -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,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(customerId);
expect(customer.products.length).toBeGreaterThan(0);
},
300_000,
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
);

View File

@@ -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,
);

View File

@@ -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> = {},
): 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> = {},
): 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);
});
});

View File

@@ -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;

View File

@@ -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";

View File

@@ -0,0 +1 @@
export const BILLING_AMOUNT_EPSILON = 0.01;

View File

@@ -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;
}