chore: discount line items directly
This commit is contained in:
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BILLING_AMOUNT_EPSILON = 0.01;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user