chore: discount line items directly
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
BILLING_AMOUNT_EPSILON,
|
||||||
cusEntToCusPrice,
|
cusEntToCusPrice,
|
||||||
InternalError,
|
InternalError,
|
||||||
|
type LineItem,
|
||||||
type LineItemContext,
|
type LineItemContext,
|
||||||
orgToCurrency,
|
orgToCurrency,
|
||||||
priceToProrationConfig,
|
priceToProrationConfig,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod";
|
import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod";
|
||||||
|
import { getRefundLineItemForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice";
|
||||||
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
|
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
|
||||||
import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade";
|
import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade";
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ export const computeAllocatedInvoiceLineItems = ({
|
|||||||
customerProduct,
|
customerProduct,
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousLIneItem = usagePriceToLineItem({
|
const catalogRefundLineItem = usagePriceToLineItem({
|
||||||
cusEnt: previousCustomerEntitlement,
|
cusEnt: previousCustomerEntitlement,
|
||||||
context: {
|
context: {
|
||||||
...lineItemContext,
|
...lineItemContext,
|
||||||
@@ -78,6 +81,14 @@ export const computeAllocatedInvoiceLineItems = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previousLineItem = getRefundLineItemForPrice({
|
||||||
|
ctx,
|
||||||
|
customerProduct,
|
||||||
|
billingContext,
|
||||||
|
priceId: customerPrice.price.id,
|
||||||
|
catalogFallback: catalogRefundLineItem,
|
||||||
|
});
|
||||||
|
|
||||||
const newLineItem = usagePriceToLineItem({
|
const newLineItem = usagePriceToLineItem({
|
||||||
cusEnt: billingContext.updatedCustomerEntitlement,
|
cusEnt: billingContext.updatedCustomerEntitlement,
|
||||||
context: lineItemContext,
|
context: lineItemContext,
|
||||||
@@ -87,15 +98,16 @@ export const computeAllocatedInvoiceLineItems = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't return line items if they sum to 0
|
const netAmount = Math.abs(
|
||||||
if (
|
|
||||||
sumValues([
|
sumValues([
|
||||||
previousLIneItem?.amountAfterDiscounts ?? 0,
|
previousLineItem?.amountAfterDiscounts ?? 0,
|
||||||
newLineItem?.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";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.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 { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext.js";
|
||||||
import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js";
|
import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js";
|
||||||
import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js";
|
import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js";
|
||||||
@@ -108,6 +109,12 @@ export const setupAllocatedInvoiceContext = async ({
|
|||||||
cusEnt: newCustomerEntitlement,
|
cusEnt: newCustomerEntitlement,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { storedChargeLineItems, storedRefundLineItems } =
|
||||||
|
await fetchStoredLineItemsForBilling({
|
||||||
|
db: ctx.db,
|
||||||
|
customerProductIds: [cusProduct.id],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// BillingContext fields
|
// BillingContext fields
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
@@ -120,6 +127,8 @@ export const setupAllocatedInvoiceContext = async ({
|
|||||||
stripeSubscription,
|
stripeSubscription,
|
||||||
stripeSubscriptionSchedule,
|
stripeSubscriptionSchedule,
|
||||||
stripeDiscounts,
|
stripeDiscounts,
|
||||||
|
storedChargeLineItems,
|
||||||
|
storedRefundLineItems,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
billingVersion: BillingVersion.V2,
|
billingVersion: BillingVersion.V2,
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic
|
|||||||
import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent";
|
import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent";
|
||||||
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
||||||
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
|
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
|
||||||
|
import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
|
||||||
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
|
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
|
||||||
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
|
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
|
||||||
import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior";
|
import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior";
|
||||||
@@ -261,6 +262,17 @@ export const setupAttachBillingContext = async ({
|
|||||||
contextOverride,
|
contextOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const outgoingCusProductIds = currentCustomerProduct
|
||||||
|
? [currentCustomerProduct.id]
|
||||||
|
: [];
|
||||||
|
const { storedChargeLineItems, storedRefundLineItems } =
|
||||||
|
await fetchStoredLineItemsForSubscriptionBilling({
|
||||||
|
db: ctx.db,
|
||||||
|
fullCustomer,
|
||||||
|
stripeSubscription,
|
||||||
|
outgoingCusProductIds,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
fullProducts: [attachProduct],
|
fullProducts: [attachProduct],
|
||||||
@@ -321,6 +333,9 @@ export const setupAttachBillingContext = async ({
|
|||||||
skipBillingChanges,
|
skipBillingChanges,
|
||||||
dryRunStripe: preview,
|
dryRunStripe: preview,
|
||||||
|
|
||||||
|
storedChargeLineItems,
|
||||||
|
storedRefundLineItems,
|
||||||
|
|
||||||
anchorResetRefund: setupAnchorResetRefund({
|
anchorResetRefund: setupAnchorResetRefund({
|
||||||
billingCycleAnchor: params.billing_cycle_anchor,
|
billingCycleAnchor: params.billing_cycle_anchor,
|
||||||
prorationBehavior: params.proration_behavior,
|
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 { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext";
|
||||||
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
|
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
|
||||||
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
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 { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||||
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
||||||
@@ -226,6 +227,17 @@ export const setupImmediateMultiProductBillingContext = async ({
|
|||||||
(productContext) => productContext.customEnts,
|
(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 {
|
return {
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
fullProducts,
|
fullProducts,
|
||||||
@@ -265,5 +277,7 @@ export const setupImmediateMultiProductBillingContext = async ({
|
|||||||
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
|
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
|
||||||
checkoutSessionParams: params.checkout_session_params,
|
checkoutSessionParams: params.checkout_session_params,
|
||||||
dryRunStripe: preview,
|
dryRunStripe: preview,
|
||||||
|
storedChargeLineItems,
|
||||||
|
storedRefundLineItems,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BillingContext } from "@autumn/shared";
|
import type { BillingContext } from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
|
BILLING_AMOUNT_EPSILON,
|
||||||
type BillingPeriod,
|
type BillingPeriod,
|
||||||
cloneEntitlementWithUpdatedQuantity,
|
cloneEntitlementWithUpdatedQuantity,
|
||||||
cusEntToCusPrice,
|
cusEntToCusPrice,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
findPrepaidCustomerEntitlement,
|
findPrepaidCustomerEntitlement,
|
||||||
InternalError,
|
InternalError,
|
||||||
|
type LineItem,
|
||||||
type LineItemContext,
|
type LineItemContext,
|
||||||
orgToCurrency,
|
orgToCurrency,
|
||||||
priceToProrationConfig,
|
priceToProrationConfig,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
usagePriceToLineItem,
|
usagePriceToLineItem,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import { getRefundLineItemForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemForPrice";
|
||||||
|
|
||||||
export const computeUpdateQuantityLineItems = ({
|
export const computeUpdateQuantityLineItems = ({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -88,7 +91,7 @@ export const computeUpdateQuantityLineItems = ({
|
|||||||
customerProduct,
|
customerProduct,
|
||||||
};
|
};
|
||||||
|
|
||||||
const refundLineItem = usagePriceToLineItem({
|
const catalogRefundLineItem = usagePriceToLineItem({
|
||||||
cusEnt: prepaidCustomerEntitlement,
|
cusEnt: prepaidCustomerEntitlement,
|
||||||
context: {
|
context: {
|
||||||
...lineItemContext,
|
...lineItemContext,
|
||||||
@@ -100,6 +103,14 @@ export const computeUpdateQuantityLineItems = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refundLineItem = getRefundLineItemForPrice({
|
||||||
|
ctx,
|
||||||
|
customerProduct,
|
||||||
|
billingContext,
|
||||||
|
priceId: customerPrice.price.id,
|
||||||
|
catalogFallback: catalogRefundLineItem,
|
||||||
|
});
|
||||||
|
|
||||||
const chargeLineItem = usagePriceToLineItem({
|
const chargeLineItem = usagePriceToLineItem({
|
||||||
cusEnt: newCustomerEntitlement,
|
cusEnt: newCustomerEntitlement,
|
||||||
context: lineItemContext,
|
context: lineItemContext,
|
||||||
@@ -109,15 +120,16 @@ export const computeUpdateQuantityLineItems = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't return line items if they sum to 0
|
const netAmount = Math.abs(
|
||||||
if (
|
|
||||||
sumValues([
|
sumValues([
|
||||||
refundLineItem?.amountAfterDiscounts ?? 0,
|
refundLineItem?.amountAfterDiscounts ?? 0,
|
||||||
chargeLineItem?.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 { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
|
||||||
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
||||||
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
||||||
|
import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
|
||||||
import { setupAttachCheckoutMode } from "../../attach/setup/setupAttachCheckoutMode";
|
import { setupAttachCheckoutMode } from "../../attach/setup/setupAttachCheckoutMode";
|
||||||
import { setupUpdateSubscriptionIntent } from "./setupUpdateSubscriptionIntent";
|
import { setupUpdateSubscriptionIntent } from "./setupUpdateSubscriptionIntent";
|
||||||
import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
|
import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
|
||||||
@@ -176,6 +177,14 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
|||||||
customerProduct,
|
customerProduct,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { storedChargeLineItems, storedRefundLineItems } =
|
||||||
|
await fetchStoredLineItemsForSubscriptionBilling({
|
||||||
|
db: ctx.db,
|
||||||
|
fullCustomer,
|
||||||
|
stripeSubscription,
|
||||||
|
outgoingCusProductIds: [customerProduct.id],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
intent,
|
intent,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
@@ -217,6 +226,9 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
|||||||
skipBillingChanges,
|
skipBillingChanges,
|
||||||
dryRunStripe: preview,
|
dryRunStripe: preview,
|
||||||
|
|
||||||
|
storedChargeLineItems,
|
||||||
|
storedRefundLineItems,
|
||||||
|
|
||||||
checkoutMode,
|
checkoutMode,
|
||||||
|
|
||||||
anchorResetRefund: setupAnchorResetRefund({
|
anchorResetRefund: setupAnchorResetRefund({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
UpdateCustomerEntitlement,
|
UpdateCustomerEntitlement,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
|
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 type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||||
import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems";
|
import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems";
|
||||||
import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems";
|
import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems";
|
||||||
@@ -55,11 +56,10 @@ export const buildAutumnLineItems = ({
|
|||||||
|
|
||||||
// Get line items for ongoing cus product
|
// Get line items for ongoing cus product
|
||||||
const deletedLineItems = customerProductsToDelete.flatMap((customerProduct) =>
|
const deletedLineItems = customerProductsToDelete.flatMap((customerProduct) =>
|
||||||
customerProductToLineItems({
|
getRefundLineItems({
|
||||||
ctx,
|
ctx,
|
||||||
customerProduct,
|
customerProduct,
|
||||||
billingContext,
|
billingContext,
|
||||||
direction: "refund",
|
|
||||||
priceFilters: { excludeOneOffPrices: true },
|
priceFilters: { excludeOneOffPrices: true },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getDeleteCustomerProducts,
|
getDeleteCustomerProducts,
|
||||||
getUpdateCustomerProducts,
|
getUpdateCustomerProducts,
|
||||||
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
|
} 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) => ({
|
const formatLineItem = (item: LineItem) => ({
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -141,9 +141,9 @@ export const buildSharedSubscriptionTrialLineItems = ({
|
|||||||
const lineItems: LineItem[] = [];
|
const lineItems: LineItem[] = [];
|
||||||
for (const customerProduct of siblingCustomerProducts) {
|
for (const customerProduct of siblingCustomerProducts) {
|
||||||
lineItems.push(
|
lineItems.push(
|
||||||
...customerProductToLineItems({
|
...getLineItemsForDirection({
|
||||||
ctx,
|
ctx,
|
||||||
customerProduct: customerProduct,
|
customerProduct,
|
||||||
billingContext,
|
billingContext,
|
||||||
direction,
|
direction,
|
||||||
priceFilters: { excludeOneOffPrices: true },
|
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 { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
|
||||||
import { filterStripeDiscountsForNextCycle } from "@/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle";
|
import { filterStripeDiscountsForNextCycle } from "@/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle";
|
||||||
import { customerProductToArrearLineItems } from "../../lineItems/customerProductToArrearLineItems";
|
import { customerProductToArrearLineItems } from "../../lineItems/customerProductToArrearLineItems";
|
||||||
import { customerProductToLineItems } from "../../lineItems/customerProductToLineItems";
|
import { getLineItemsForDirection } from "../../lineItems/getLineItemsForDirection";
|
||||||
import { lineItemToPreviewLineItem } from "../../lineItems/lineItemToPreviewLineItem";
|
import { lineItemToPreviewLineItem } from "../../lineItems/lineItemToPreviewLineItem";
|
||||||
import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPreviewUsageLineItem";
|
import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPreviewUsageLineItem";
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const buildLineItemsForSpec = ({
|
|||||||
nextCycleStart: number;
|
nextCycleStart: number;
|
||||||
}) => {
|
}) => {
|
||||||
const lineItems = spec.customerProducts.flatMap((customerProduct) =>
|
const lineItems = spec.customerProducts.flatMap((customerProduct) =>
|
||||||
customerProductToLineItems({
|
getLineItemsForDirection({
|
||||||
ctx,
|
ctx,
|
||||||
customerProduct,
|
customerProduct,
|
||||||
billingContext: {
|
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 { deleteByInvoiceId } from "./deleteByInvoiceId";
|
||||||
import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId";
|
import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId";
|
||||||
|
import { getByCustomerProductAndPeriod } from "./getByCustomerProductAndPeriod";
|
||||||
import { getByInvoiceId } from "./getByInvoiceId";
|
import { getByInvoiceId } from "./getByInvoiceId";
|
||||||
import { getByInvoiceIds } from "./getByInvoiceIds";
|
import { getByInvoiceIds } from "./getByInvoiceIds";
|
||||||
import { getByStripeInvoiceId } from "./getByStripeInvoiceId";
|
import { getByStripeInvoiceId } from "./getByStripeInvoiceId";
|
||||||
@@ -18,6 +19,7 @@ export const invoiceLineItemRepo = {
|
|||||||
getByInvoiceId,
|
getByInvoiceId,
|
||||||
getByInvoiceIds,
|
getByInvoiceIds,
|
||||||
getByStripeInvoiceId,
|
getByStripeInvoiceId,
|
||||||
|
getByCustomerProductAndPeriod,
|
||||||
deleteByInvoiceId,
|
deleteByInvoiceId,
|
||||||
deleteStaleByStripeInvoiceId,
|
deleteStaleByStripeInvoiceId,
|
||||||
getDeferredByInvoiceItemIds,
|
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";
|
} from "@autumn/shared";
|
||||||
import type { PaymentBehaviorIntent } from "@models/billingModels/context/paymentBehaviorIntent";
|
import type { PaymentBehaviorIntent } from "@models/billingModels/context/paymentBehaviorIntent";
|
||||||
import type { TransitionConfig } from "@models/billingModels/context/transitionConfig";
|
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 { EntInterval } from "@models/productModels/intervals/entitlementInterval";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -112,6 +113,9 @@ export interface BillingContext {
|
|||||||
|
|
||||||
anchorResetRefund?: AnchorResetRefund;
|
anchorResetRefund?: AnchorResetRefund;
|
||||||
|
|
||||||
|
storedChargeLineItems?: DbInvoiceLineItem[];
|
||||||
|
storedRefundLineItems?: DbInvoiceLineItem[];
|
||||||
|
|
||||||
refundLastPayment?: "prorated" | "full";
|
refundLastPayment?: "prorated" | "full";
|
||||||
|
|
||||||
paymentBehaviorIntent?: PaymentBehaviorIntent;
|
paymentBehaviorIntent?: PaymentBehaviorIntent;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./intervalUtils/intervalArithmetic";
|
|||||||
// Invoicing utils
|
// Invoicing utils
|
||||||
|
|
||||||
export * from "./invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.js";
|
export * from "./invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.js";
|
||||||
|
export * from "./invoicingUtils/billingConstants.js";
|
||||||
export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js";
|
export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js";
|
||||||
export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js";
|
export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js";
|
||||||
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.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 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
|
* 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) {
|
if (matchingChargeIndex !== -1) {
|
||||||
const matchingChargeItem = chargeItems[matchingChargeIndex];
|
const matchingChargeItem = chargeItems[matchingChargeIndex];
|
||||||
const total = refundItem.amount + matchingChargeItem.amount;
|
const netAmount = Math.abs(refundItem.amount + matchingChargeItem.amount);
|
||||||
|
|
||||||
if (total === 0) {
|
if (netAmount < BILLING_AMOUNT_EPSILON) {
|
||||||
// Amounts cancel out - mark charge item as matched (both will be removed)
|
|
||||||
matchedChargeIndices.add(matchingChargeIndex);
|
matchedChargeIndices.add(matchingChargeIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user