This commit is contained in:
John Yeo
2025-12-16 13:05:57 +00:00
parent 245943be7b
commit 07340cd79b
62 changed files with 1770 additions and 650 deletions

View File

@@ -0,0 +1,49 @@
import {
ApiVersion,
InternalError,
isConsumablePrice,
type Price,
} from "@autumn/shared";
export const consumablePriceToStripeItem = ({
price,
isCheckout,
withEntity,
apiVersion,
fromVercel,
}: {
price: Price;
isCheckout: boolean;
withEntity: boolean;
apiVersion?: ApiVersion;
fromVercel: boolean;
}) => {
if (!isConsumablePrice(price)) {
throw new InternalError({
message: `[consumablePriceToStripeItem] Price ${price.id} is not a consumable price`,
});
}
const config = price.config;
const priceId = config.stripe_price_id;
const newUsageMethod =
withEntity || apiVersion === ApiVersion.V1_Beta || fromVercel;
if (newUsageMethod && !isCheckout) {
return {
price: config.stripe_empty_price_id,
quantity: 0,
};
}
if (!priceId) {
throw new InternalError({
message: `[consumablePriceToStripeItem] config.stripe_price_id is empty for autumn price: ${price.id}`,
});
}
return {
price: priceId,
};
};

View File

@@ -1,24 +1,20 @@
import {
ApiVersion,
type ApiVersion,
BillingType,
type EntitlementWithFeature,
ErrCode,
type FeatureOptions,
type FixedPriceConfig,
type FullProduct,
InternalError,
type Organization,
type Price,
type ProductOptions,
type UsagePriceConfig,
} from "@autumn/shared";
import {
getBillingType,
priceIsOneOffAndTiered,
} from "@server/internal/products/prices/priceUtils";
import RecaseError from "@server/utils/errorUtils";
import { notNullish } from "@server/utils/genUtils";
import { billingIntervalToStripe } from "../stripePriceUtils";
import { consumablePriceToStripeItem } from "./consumablePriceToStripeItem";
import { priceToInArrearProrated } from "./priceToArrearProrated";
import {
priceToOneOffAndTiered,
@@ -59,28 +55,30 @@ export const priceToStripeItem = ({
withEntity = false,
isCheckout = false,
apiVersion,
productOptions,
// productOptions,
fromVercel = false,
}: {
price: Price;
relatedEnt: EntitlementWithFeature;
relatedEnt?: EntitlementWithFeature;
product: FullProduct;
org: Organization;
options: FeatureOptions | undefined | null;
existingUsage: number;
existingUsage?: number;
withEntity: boolean;
isCheckout: boolean;
apiVersion?: ApiVersion;
productOptions?: ProductOptions | undefined;
// productOptions?: ProductOptions | undefined;
fromVercel?: boolean;
}) => {
// TODO: Implement this
const billingType = getBillingType(price.config!);
const stripeProductId = product.processor?.id;
const quantityMultiplier = notNullish(productOptions?.quantity)
? productOptions?.quantity
: 1;
// const quantityMultiplier = notNullish(productOptions?.quantity)
// ? productOptions?.quantity
// : 1;
const quantityMultiplier = 1;
if (!stripeProductId) {
throw new InternalError({
@@ -88,7 +86,6 @@ export const priceToStripeItem = ({
});
}
const lineItemMeta = null;
let lineItem = null;
// 1. FIXED PRICE
@@ -102,10 +99,16 @@ export const priceToStripeItem = ({
price: config.stripe_price_id,
quantity: quantityMultiplier,
};
return { lineItem };
}
if (!relatedEnt) {
return null;
}
// 2. PREPAID, TIERED, ONE OFF
else if (
if (
billingType === BillingType.UsageInAdvance &&
priceIsOneOffAndTiered(price, relatedEnt)
) {
@@ -129,32 +132,13 @@ export const priceToStripeItem = ({
// 4. USAGE IN ARREAR
else if (billingType === BillingType.UsageInArrear) {
const config = price.config as UsagePriceConfig;
const priceId = config.stripe_price_id;
const newUsageMethod =
withEntity || apiVersion === ApiVersion.V1_Beta || fromVercel;
if (newUsageMethod && !isCheckout) {
return {
lineItem: {
price: config.stripe_empty_price_id,
quantity: 0,
},
};
}
if (!priceId) {
throw new RecaseError({
code: ErrCode.PriceNotFound,
message: `Couldn't find Autumn price: ${price.id} in Stripe`,
statusCode: 400,
});
}
lineItem = {
price: priceId,
};
lineItem = consumablePriceToStripeItem({
price,
isCheckout,
withEntity,
apiVersion,
fromVercel,
});
}
// 5. USAGE ARREAR PRORATED
@@ -162,7 +146,7 @@ export const priceToStripeItem = ({
lineItem = priceToInArrearProrated({
price,
isCheckout,
existingUsage,
existingUsage: existingUsage ?? 0,
});
}
@@ -172,6 +156,5 @@ export const priceToStripeItem = ({
return {
lineItem,
lineItemMeta,
};
};

View File

@@ -34,6 +34,7 @@ export const priceToOneOffAndTiered = ({
);
}
return {
price: undefined,
price_data: {
product: config.stripe_product_id
? config.stripe_product_id

View File

@@ -0,0 +1,31 @@
import type Stripe from "stripe";
export const stripeInvoiceLineItemToPriceId = (
lineItem: Stripe.InvoiceLineItem,
) => {
const priceId = lineItem.pricing?.price_details?.price;
if (!priceId) {
return undefined;
}
if (typeof priceId !== "string") {
throw new Error("lineItem.pricing.price_details.price is not a string");
}
return priceId;
};
export const stripeInvoiceLineItemToProductId = (
lineItem: Stripe.InvoiceLineItem,
) => {
const productId = lineItem.pricing?.price_details?.product;
if (!productId) {
return undefined;
}
if (typeof productId !== "string") {
throw new Error("lineItem.pricing.price_details.product is not a string");
}
return productId;
};

View File

@@ -10,7 +10,7 @@ export const getLatestPeriodEnd = ({
if (!subItems) {
subItems = sub?.items.data || [];
}
if (subItems.length == 0) {
if (subItems.length === 0) {
return Date.now();
}
@@ -20,7 +20,7 @@ export const getLatestPeriodEnd = ({
};
export const getEarliestPeriodEnd = ({ sub }: { sub: Stripe.Subscription }) => {
if (sub.items.data.length == 0) {
if (sub.items.data.length === 0) {
return Date.now();
}

View File

@@ -166,14 +166,14 @@ export const getStripeSubItems = async ({
existingUsage,
withEntity: notNullish(attachParams.internalEntityId),
apiVersion: attachParams.apiVersion,
productOptions: prodOptions,
// productOptions: prodOptions,
});
if (!stripeItem) {
continue;
}
const { lineItem } = stripeItem;
const lineItem = stripeItem;
subItems.push(lineItem);
}

View File

@@ -0,0 +1,30 @@
import type { CusProductActions, FullCusProduct } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction";
import { insertNewCusProducts } from "./insertNewCusProducts";
export const applyCusProductActions = async ({
ctx,
cusProductActions,
newCusProducts,
}: {
ctx: AutumnContext;
cusProductActions: CusProductActions;
newCusProducts: FullCusProduct[];
}) => {
// 1. Insert new cus products
await insertNewCusProducts({
ctx,
newCusProducts,
});
const { ongoingCusProductAction } = cusProductActions;
// 2. Apply ongoing cus product action
if (ongoingCusProductAction) {
await applyOngoingCusProductAction({
ctx,
ongoingCusProductAction,
});
}
};

View File

@@ -1,28 +0,0 @@
import type {
FullCustomer,
InsertFullCusProductContext,
NewProductAction,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { initFullCusProduct } from "../initFullCusProduct/initFullCusProduct";
export const applyNewProductAction = async ({
ctx,
fullCus,
newProductAction,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
newProductAction: NewProductAction;
}) => {
const insertContext: InsertFullCusProductContext = {
fullCus,
product: newProductAction.product,
featureQuantities: [],
replaceables: [],
};
if (newProductAction.timing === "scheduled") {
return await initFullCusProduct({ ctx, fullCus, insertContext });
}
};

View File

@@ -0,0 +1,46 @@
import { CusProductStatus, type OngoingCusProductAction } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { CusProductService } from "../../../customers/cusProducts/CusProductService";
export const applyOngoingCusProductAction = async ({
ctx,
ongoingCusProductAction,
}: {
ctx: AutumnContext;
ongoingCusProductAction: OngoingCusProductAction;
}) => {
const { action, cusProduct } = ongoingCusProductAction;
if (action === "expire") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
},
});
}
if (action === "cancel") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
canceled: true,
canceled_at: Date.now(),
// TODO: add ended_at
},
});
}
if (action === "uncancel") {
return await CusProductService.update({
db: ctx.db,
cusProductId: cusProduct.id,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
}
};

View File

@@ -0,0 +1,48 @@
import type { FullCusProduct } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { CusProductService } from "../../../customers/cusProducts/CusProductService";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService";
import { RolloverService } from "../../../customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { CusPriceService } from "../../../customers/cusProducts/cusPrices/CusPriceService";
export const insertNewCusProducts = async ({
ctx,
newCusProducts,
}: {
ctx: AutumnContext;
newCusProducts: FullCusProduct[];
}) => {
const cusEnts = newCusProducts.flatMap(
(cusProduct) => cusProduct.customer_entitlements,
);
const cusPrices = newCusProducts.flatMap(
(cusProduct) => cusProduct.customer_prices,
);
// 4. Insert cusProducts
await CusProductService.insert({
db: ctx.db,
data: newCusProducts,
});
// 2. Insert cusEnts
await CusEntService.insert({
db: ctx.db,
data: cusEnts,
});
// 3. Insert cusPrices
await CusPriceService.insert({
db: ctx.db,
data: cusPrices,
});
// 1. Insert rollovers
for (const cusEnt of cusEnts) {
await RolloverService.insert({
db: ctx.db,
rows: cusEnt.rollovers,
fullCusEnt: cusEnt,
});
}
};

View File

@@ -10,8 +10,13 @@ import {
} from "@autumn/shared";
import { createStripeCli } from "../../../../external/connect/createStripeCli";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { applyCusProductActions } from "../applyCusProductActions/applyCusProductActions";
import { cusProductToExistingUsages } from "../handleExistingUsages/cusProductToExistingUsages";
import { initFullCusProduct } from "../initFullCusProduct/initFullCusProduct";
import { applyStripeDiscountsToLineItems } from "../stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
import { subToDiscounts } from "../stripeAdapter/applyStripeDiscounts/subToDiscounts";
import { buildSubItemUpdate } from "../stripeAdapter/buildSubItems/buildSubItemUpdate";
import { createAndPayInvoice } from "../stripeAdapter/stripeInvoicing/createAndPayInvoice";
export const enrichAttachActions = async ({
ctx,
@@ -31,7 +36,7 @@ export const enrichAttachActions = async ({
const { sub, testClockFrozenTime } = attachContext;
const billingCycleAnchor = secondsToMs(sub?.billing_cycle_anchor);
const product = attachContext.products[0];
const ongoingCusProduct = ongoingCusProductAction?.cusProduct!;
const ongoingCusProduct = ongoingCusProductAction?.cusProduct;
// Get latest cycle end for each product
const largestInterval = getLargestInterval({
@@ -58,6 +63,77 @@ export const enrichAttachActions = async ({
},
});
console.log(`ongoing cus product:, ${ongoingCusProduct?.product.name}`);
console.log(`new cus product:, ${newCusProduct.product.name}`);
console.log(`billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
console.log(`test clock frozen time: ${formatMs(testClockFrozenTime)}`);
const arrearLineItems = cusProductToArrearLineItems({
cusProduct: ongoingCusProduct!,
billingCycleAnchor: billingCycleAnchor!,
testClockFrozenTime,
org,
});
// Get line items for ongoing cus product
const ongoingLineItems = cusProductToLineItems({
cusProduct: ongoingCusProduct!,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "refund",
org,
});
const newLineItems = cusProductToLineItems({
cusProduct: newCusProduct,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "charge",
org,
});
// All items
const allLineItems = [
...ongoingLineItems,
...arrearLineItems,
...newLineItems,
];
// 1. Get discounts from sub, 2. GET NEW DISCOUNT
const subDiscounts = subToDiscounts({ sub });
const lineItemsAfterDiscounts = applyStripeDiscountsToLineItems({
lineItems: allLineItems,
discounts: subDiscounts,
});
await createAndPayInvoice({
stripeCli,
stripeCusId: fullCus.processor?.id || "",
lineItems: lineItemsAfterDiscounts,
paymentMethod: attachContext.paymentMethod,
onPaymentFailure: "throw",
});
// Build sub item update
const subItemUpdate = buildSubItemUpdate({
ctx,
attachContext,
ongoingCusProduct,
newCusProducts: [newCusProduct],
});
await stripeCli.subscriptions.update(sub?.id || "", {
items: subItemUpdate,
proration_behavior: "none",
});
await applyCusProductActions({
ctx,
cusProductActions: actions,
newCusProducts: [newCusProduct],
});
return actions;
// 1. Get the starts at if new product is scheduled
@@ -68,47 +144,4 @@ export const enrichAttachActions = async ({
// 6. Get existing usages
// 1. Calculate line items for usages
// const newCusProduct = newProductAction?.product;
console.log(`ongoing cus product:, ${ongoingCusProduct?.product.name}`);
console.log(`new cus product:, ${newCusProduct.product.name}`);
console.log(`billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
console.log(`test clock frozen time: ${formatMs(testClockFrozenTime)}`);
// Get line items for ongoing cus product
const ongoingLineItems = cusProductToLineItems({
cusProduct: ongoingCusProduct!,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "refund",
});
const arrearLineItems = cusProductToArrearLineItems({
cusProduct: ongoingCusProduct!,
billingCycleAnchor: billingCycleAnchor!,
testClockFrozenTime,
});
const newLineItems = cusProductToLineItems({
cusProduct: newCusProduct,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "charge",
});
// From billing cycle anchor, now, and interval, calculate latest cycle start:
// if (largestInterval && billingCycleAnchor) {
// const cycleStart = getCycleStart({
// anchor: billingCycleAnchor,
// interval: largestInterval.interval,
// intervalCount: largestInterval.intervalCount,
// testClockFrozenTime,
// });
// console.log(`Now: ${formatMs(testClockFrozenTime)}`);
// console.log(`Billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
// console.log(`Cycle start: ${formatMs(cycleStart)}`);
// }
return actions;
};

View File

@@ -1,49 +0,0 @@
// import { CusProductStatus, type CusProductActions } from "@autumn/shared";
// import type { AutumnContext } from "../../../honoUtils/HonoEnv";
// import { CusProductService } from "../../customers/cusProducts/CusProductService";
// export const executeActiveCusProductAction = async ({
// ctx,
// ongoingCusProductAction,
// }: {
// ctx: AutumnContext;
// ongoingCusProductAction?: OngoingCusProductAction;
// }) => {
// if (!activeCusProductAction) return;
// const { action, cusProduct } = activeCusProductAction;
// if (action === "expire") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// status: CusProductStatus.Expired,
// },
// });
// }
// if (action === "cancel") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// canceled: true,
// canceled_at: Date.now(),
// // TODO: add ended_at
// },
// });
// }
// if (action === "uncancel") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// canceled: false,
// canceled_at: null,
// ended_at: null,
// },
// });
// }
// };

View File

@@ -28,7 +28,12 @@ export const getAttachSub = async ({
if (!subId) return { sub: undefined };
const sub = await stripeCli.subscriptions.retrieve(subId);
const sub = await stripeCli.subscriptions.retrieve(subId, {
expand: [
"discounts.source.coupon.applies_to",
"latest_invoice.lines.data.discount_amounts",
],
});
return { sub };
};

View File

@@ -16,9 +16,9 @@ export const applyExistingUsages = ({
existingUsages?: ExistingUsages;
entities: Entity[];
}) => {
console.log(
`applying existing usages to new cus product: ${cusProduct.product.name}`,
);
// console.log(
// `applying existing usages to new cus product: ${cusProduct.product.name}`,
// );
// 1. Merge entities with existing usages
const mergedExistingUsages = mergeEntitiesWithExistingUsages({
@@ -29,10 +29,10 @@ export const applyExistingUsages = ({
for (const [internalFeatureId, existingUsage] of Object.entries(
mergedExistingUsages,
)) {
console.log(
`Applying existing usage for feature: ${internalFeatureId}, usage: `,
existingUsage,
);
// console.log(
// `Applying existing usage for feature: ${internalFeatureId}, usage: `,
// existingUsage,
// );
const cusEnts = cusProductsToCusEnts({
cusProducts: [cusProduct],

View File

@@ -0,0 +1,100 @@
import {
type LineItem,
type LineItemDiscount,
type StripeDiscountWithCoupon,
stripeToAtmnAmount,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
/**
* Applies an amount_off discount to line items.
* Distributes the fixed amount proportionally within each direction group (refund/charge).
*/
export const applyAmountOffDiscountToLineItems = ({
lineItems,
discount,
}: {
lineItems: LineItem[];
discount: StripeDiscountWithCoupon;
}): LineItem[] => {
const coupon = discount.source.coupon;
const amountOffCents = coupon.amount_off;
if (!amountOffCents || amountOffCents === 0) {
return lineItems;
}
// Convert from Stripe cents to Autumn dollars
const discountAmountOff = stripeToAtmnAmount({
amount: amountOffCents,
currency: coupon.currency ?? "usd",
});
// Filter to applicable line items
const applicableItems = lineItems.filter((item) =>
discountAppliesToLineItem({ discount, lineItem: item }),
);
if (applicableItems.length === 0) return lineItems;
// Build a map of line item -> discount amount
const discountMap = new Map<LineItem, number>();
// Helper to distribute discount proportionally across items
const distributeDiscount = (items: LineItem[]) => {
const total = items.reduce((sum, item) => sum + Math.abs(item.amount), 0);
if (total === 0) return;
for (const item of items) {
const proportion = new Decimal(Math.abs(item.amount)).dividedBy(total);
const itemDiscount = proportion
.times(discountAmountOff)
.round()
.toNumber();
discountMap.set(item, itemDiscount);
}
};
// Group by direction and distribute separately
const refundItems = applicableItems.filter(
(item) => item.context.direction === "refund",
);
const chargeItems = applicableItems.filter(
(item) => item.context.direction === "charge",
);
distributeDiscount(refundItems);
distributeDiscount(chargeItems);
// Apply discounts to line items
return lineItems.map((item) => {
const itemDiscount = discountMap.get(item);
if (!itemDiscount || itemDiscount === 0) return item;
const newDiscount: LineItemDiscount = {
amountOff: itemDiscount,
stripeCouponId: coupon.id,
};
const existingDiscounts = item.discounts ?? [];
const totalDiscount =
existingDiscounts.reduce((sum, d) => sum + d.amountOff, 0) + itemDiscount;
// Calculate finalAmount based on direction
// Refund (negative): add discount to make less negative
// Charge (positive): subtract discount to reduce charge
const finalAmount =
item.context.direction === "refund"
? new Decimal(item.amount).plus(totalDiscount).toNumber()
: new Decimal(item.amount).minus(totalDiscount).toNumber();
return {
...item,
discounts: [...existingDiscounts, newDiscount],
finalAmount,
};
});
};

View File

@@ -0,0 +1,66 @@
import type {
LineItem,
LineItemDiscount,
StripeDiscountWithCoupon,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
/**
* Applies a percent_off discount to line items.
* Applies the percentage to each applicable line item individually.
*/
export const applyPercentOffDiscountToLineItems = ({
lineItems,
discount,
}: {
lineItems: LineItem[];
discount: StripeDiscountWithCoupon;
}): LineItem[] => {
const coupon = discount.source.coupon;
const percentOff = coupon.percent_off;
if (!percentOff || percentOff === 0) {
return lineItems;
}
return lineItems.map((item) => {
// Check if discount applies to this line item
if (!discountAppliesToLineItem({ discount, lineItem: item })) {
return item;
}
// Calculate discount amount: |amount| * (percentOff / 100)
const itemDiscount = new Decimal(Math.abs(item.amount))
.times(percentOff)
.dividedBy(100)
.round()
.toNumber();
if (itemDiscount === 0) return item;
const newDiscount: LineItemDiscount = {
amountOff: itemDiscount,
percentOff,
stripeCouponId: coupon.id,
};
const existingDiscounts = item.discounts ?? [];
const totalDiscount =
existingDiscounts.reduce((sum, d) => sum + d.amountOff, 0) + itemDiscount;
// Calculate finalAmount based on direction
// Refund (negative): add discount to make less negative
// Charge (positive): subtract discount to reduce charge
const finalAmount =
item.context.direction === "refund"
? new Decimal(item.amount).plus(totalDiscount).toNumber()
: new Decimal(item.amount).minus(totalDiscount).toNumber();
return {
...item,
discounts: [...existingDiscounts, newDiscount],
finalAmount,
};
});
};

View File

@@ -0,0 +1,23 @@
import type { LineItem, StripeDiscountWithCoupon } from "@autumn/shared";
import { applyAmountOffDiscountToLineItems } from "./applyAmountOffDiscountToLineItems";
import { applyPercentOffDiscountToLineItems } from "./applyPercentOffDiscountToLineItems";
export const applyStripeDiscountsToLineItems = ({
lineItems,
discounts,
}: {
lineItems: LineItem[];
discounts: StripeDiscountWithCoupon[];
}): LineItem[] => {
for (const discount of discounts) {
if (discount.source.coupon.percent_off) {
lineItems = applyPercentOffDiscountToLineItems({
lineItems,
discount,
});
} else if (discount.source.coupon.amount_off) {
lineItems = applyAmountOffDiscountToLineItems({ lineItems, discount });
}
}
return lineItems;
};

View File

@@ -0,0 +1,24 @@
import type { LineItem, StripeDiscountWithCoupon } from "@autumn/shared";
/**
* Checks if a discount applies to a specific line item based on applies_to.products
*/
export const discountAppliesToLineItem = ({
discount,
lineItem,
}: {
discount: StripeDiscountWithCoupon;
lineItem: LineItem;
}): boolean => {
const appliesToProducts = discount.source.coupon.applies_to?.products;
// If no applies_to, discount applies to all products
if (!appliesToProducts || appliesToProducts.length === 0) {
return true;
}
// Check if line item's product is in the applies_to list
return lineItem.stripeProductId
? appliesToProducts.includes(lineItem.stripeProductId)
: false;
};

View File

@@ -0,0 +1,18 @@
import { notNullish } from "@autumn/shared";
import type Stripe from "stripe";
export const subToDiscounts = ({
sub,
}: {
sub?: Stripe.Subscription;
}): (Stripe.Discount & { source: { coupon: Stripe.Coupon } })[] => {
if (!sub) return [];
const discounts = sub.discounts
.map((discount) => (typeof discount === "string" ? null : discount))
.filter(notNullish) as (Stripe.Discount & {
source: { coupon: Stripe.Coupon };
})[];
return discounts;
};

View File

@@ -0,0 +1,229 @@
import {
type AttachContext,
type FullCusProduct,
filterCusProductsBySubId,
isConsumablePrice,
isCusProductOngoing,
type StripeItemSpec,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { cusProductToSubItems } from "../cusProductToSubItems";
/**
* Initialize targetItems with current sub state.
* - Regular items: set quantity
* - Metered items: set undefined (no quantity)
*/
const initializeTargetItems = ({
currentItems,
}: {
currentItems: Stripe.SubscriptionItem[];
}): Map<string, number | undefined> => {
const targetItems = new Map<string, number | undefined>();
for (const item of currentItems) {
const priceId = item.price?.id;
if (!priceId) continue;
// Metered items have no quantity (or quantity is irrelevant)
const isMetered = item.price?.recurring?.usage_type === "metered";
targetItems.set(priceId, isMetered ? undefined : (item.quantity ?? 1));
}
return targetItems;
};
/**
* Adds new items to targetItems map.
* - Consumable prices: only add if not already in map, use spec.quantity (0 or undefined)
* - Regular prices: add to existing quantity in map
*/
const addNewItems = ({
targetItems,
itemsToAdd,
}: {
targetItems: Map<string, number | undefined>;
itemsToAdd: StripeItemSpec[];
}) => {
for (const spec of itemsToAdd) {
const isConsumable =
spec.autumnPrice && isConsumablePrice(spec.autumnPrice);
// CONSUMABLE: Only add if not already in map, use quantity from spec
if (isConsumable) {
if (targetItems.has(spec.stripePriceId)) continue;
targetItems.set(spec.stripePriceId, spec.quantity); // Could be 0 or undefined
continue;
}
// REGULAR: Add to existing quantity in map
const existingQty = targetItems.get(spec.stripePriceId) ?? 0;
const newQty = (existingQty ?? 0) + (spec.quantity ?? 1);
targetItems.set(spec.stripePriceId, newQty);
}
};
/**
* Check if a cusProduct has a specific stripe price ID
*/
const cusProductHasStripePriceId = ({
cusProduct,
stripePriceId,
}: {
cusProduct: FullCusProduct;
stripePriceId: string;
}): boolean => {
return cusProduct.customer_prices.some(
(cp) =>
cp.price.config.stripe_price_id === stripePriceId ||
cp.price.config.stripe_empty_price_id === stripePriceId,
);
};
/**
* Removes old items from targetItems map.
* - Consumable: keep if ANY remaining cusProduct needs it, otherwise delete
* - Regular: always subtract quantity, delete if <= 0
*/
const removeOldItems = ({
targetItems,
itemsToRemove,
remainingCusProducts,
}: {
targetItems: Map<string, number | undefined>;
itemsToRemove: StripeItemSpec[];
remainingCusProducts: FullCusProduct[]; // All cus products AFTER operation (includes new, excludes old)
}) => {
for (const spec of itemsToRemove) {
const priceId = spec.stripePriceId;
const isConsumable =
spec.autumnPrice && isConsumablePrice(spec.autumnPrice);
// CONSUMABLE: Keep if ANY remaining cusProduct needs it
if (isConsumable) {
const anyNeedsIt = remainingCusProducts.some((cp) =>
cusProductHasStripePriceId({ cusProduct: cp, stripePriceId: priceId }),
);
if (anyNeedsIt) continue;
// No one needs it, delete
targetItems.delete(priceId);
continue;
}
// REGULAR: Always subtract quantity
const existingQty = targetItems.get(priceId) ?? 0;
const quantityToRemove = spec.quantity ?? 1;
const newQty = (existingQty ?? 0) - quantityToRemove;
if (newQty <= 0) {
targetItems.delete(priceId);
} else {
targetItems.set(priceId, newQty);
}
}
};
/**
* Convert targetItems map to Stripe subscription update params.
* Compares with currentItems to determine add/update/delete operations.
*/
const toStripeParams = ({
targetItems,
currentItems,
}: {
targetItems: Map<string, number | undefined>;
currentItems: Stripe.SubscriptionItem[];
}): Stripe.SubscriptionUpdateParams.Item[] => {
const result: Stripe.SubscriptionUpdateParams.Item[] = [];
// Handle additions and updates
for (const [priceId, quantity] of targetItems) {
const existingItem = currentItems.find((si) => si.price?.id === priceId);
if (existingItem) {
// UPDATE existing item (only if quantity changed)
const currentQty = existingItem.quantity;
if (quantity !== currentQty) {
result.push({ id: existingItem.id, quantity });
}
} else {
// ADD new item
result.push({ price: priceId, quantity });
}
}
// Handle deletions - items in current but NOT in target
for (const item of currentItems) {
const priceId = item.price?.id;
if (!priceId) continue;
if (!targetItems.has(priceId)) {
result.push({ id: item.id, deleted: true });
}
}
return result;
};
export const buildSubItemUpdate = ({
ctx,
attachContext,
ongoingCusProduct,
newCusProducts,
}: {
ctx: AutumnContext;
attachContext: AttachContext;
ongoingCusProduct?: FullCusProduct;
newCusProducts?: FullCusProduct[];
}) => {
const { fullCus, sub } = attachContext;
const currentItems = sub?.items.data || [];
const itemsToAdd =
newCusProducts?.flatMap((cusProduct) =>
cusProductToSubItems({
ctx,
cusProduct,
attachContext,
}),
) ?? [];
const itemsToRemove = ongoingCusProduct
? cusProductToSubItems({
ctx,
cusProduct: ongoingCusProduct,
attachContext,
})
: [];
// Cus products that will remain after operation (excludes old, includes existing + new)
const existingCusProducts = filterCusProductsBySubId({
cusProducts: fullCus.customer_products,
subId: sub?.id,
})
.filter((cp: FullCusProduct) => cp.id !== ongoingCusProduct?.id)
.filter((cp: FullCusProduct) => isCusProductOngoing({ cusProduct: cp }));
const remainingCusProducts = [
...existingCusProducts,
...(newCusProducts ?? []),
];
// Step 0: Initialize targetItems with current sub state
const targetItems = initializeTargetItems({ currentItems });
// Step 1: Add new items
addNewItems({ targetItems, itemsToAdd });
// Step 2: Remove old items
removeOldItems({
targetItems,
itemsToRemove,
remainingCusProducts,
});
// Step 3: Convert to Stripe params (deletions derived from diff)
return toStripeParams({ targetItems, currentItems });
};

View File

@@ -0,0 +1,187 @@
import {
type AttachContext,
addCusProductToCusEnt,
cusPriceToCusEnt,
cusProductToProduct,
entToOptions,
type FeatureOptions,
type FullCusProduct,
isAllocatedCusEnt,
notNullish,
type StripeItemSpec,
} from "@autumn/shared";
import { cusEntToInvoiceUsage } from "../../../../../../shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage";
import { priceToStripeItem } from "../../../../external/stripe/priceToStripeItem/priceToStripeItem";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
export const cusProductToSubItems = ({
ctx,
cusProduct,
attachContext,
}: {
ctx: AutumnContext;
cusProduct: FullCusProduct;
attachContext?: AttachContext;
}) => {
const product = cusProductToProduct({ cusProduct });
const cusPrices = cusProduct.customer_prices;
const cusEnts = cusProduct.customer_entitlements;
const fromVercel = attachContext?.paymentMethod?.type === "custom";
const { org } = ctx;
const stripeItems: StripeItemSpec[] = [];
for (const cusPrice of cusPrices) {
const price = cusPrice.price;
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts });
const ent = cusEnt?.entitlement;
let options: FeatureOptions | undefined;
let existingUsage: number | undefined;
if (cusEnt) {
const ent = cusEnt.entitlement;
options = entToOptions({ ent, options: cusProduct.options ?? [] });
const cusEntWithCusProduct = addCusProductToCusEnt({
cusEnt,
cusProduct,
});
if (isAllocatedCusEnt(cusEntWithCusProduct)) {
existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
}
}
const stripeItem = priceToStripeItem({
price,
product,
org,
options,
isCheckout: false, // TODO: Add this back in
relatedEnt: ent,
existingUsage,
withEntity: notNullish(cusProduct.internal_entity_id),
apiVersion: ctx.apiVersion.value,
fromVercel,
});
if (!stripeItem) {
continue;
}
const { lineItem } = stripeItem;
// subItems.push(lineItem);
stripeItems.push({
stripePriceId: lineItem?.price ?? "",
quantity: lineItem?.quantity,
autumnPrice: price,
});
}
return stripeItems;
};
// const {
// prices,
// entitlements,
// optionsList,
// cusProducts,
// customer,
// internalEntityId,
// products,
// } = attachParams;
// const subItems: any[] = [];
// const invoiceItems: any[] = [];
// const usageFeatures: any[] = [];
// for (const price of prices) {
// const priceEnt = getPriceEntitlement(price, entitlements);
// const options = getEntOptions(optionsList, priceEnt);
// const prodOptions = priceToProductOptions({
// price,
// options: attachParams.productsList,
// products,
// });
// let existingUsage = getExistingUsageFromCusProducts({
// entitlement: priceEnt,
// cusProducts,
// entities: customer.entities ?? [],
// carryExistingUsages: config.carryUsage,
// internalEntityId,
// });
// const replaceables = priceEnt
// ? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id)
// : [];
// existingUsage += replaceables.length;
// const product = getProductForPrice(price, attachParams.products)!;
// if (!product) {
// logger.error(
// `Couldn't find product for price ${price.internal_product_id}`,
// {
// data: {
// products: attachParams.products,
// price,
// },
// },
// );
// throw new InternalError({
// message: `Price internal product ID: ${price.internal_product_id} not found in products`,
// });
// }
// const stripeItem = priceToStripeItem({
// price,
// product,
// org: attachParams.org,
// options,
// isCheckout: config.onlyCheckout,
// relatedEnt: priceEnt,
// existingUsage,
// withEntity: notNullish(internalEntityId),
// apiVersion: attachParams.apiVersion,
// productOptions: prodOptions,
// fromVercel: attachParams.paymentMethod?.type === "custom",
// });
// if (isUsagePrice({ price })) {
// usageFeatures.push(priceEnt.feature.internal_id);
// }
// if (!stripeItem) {
// continue;
// }
// const { lineItem } = stripeItem;
// // subItems.push(lineItem);
// if (price.config.interval === BillingInterval.OneOff) {
// invoiceItems.push(lineItem);
// } else {
// subItems.push({
// ...lineItem,
// autumnPrice: price,
// });
// }
// }
// return { subItems, invoiceItems, usageFeatures } as ItemSet;
// if (price.config.interval === BillingInterval.OneOff) {
// invoiceItems.push({
// stripe_price_id: lineItem.price,
// quantity: lineItem.quantity,
// });
// } else {
// subItems.push({
// ...lineItem,
// autumnPrice: price,
// });
// }

View File

@@ -0,0 +1,87 @@
import type { LineItem } from "@autumn/shared";
import type Stripe from "stripe";
import { lineItemsToStripeLines } from "./lineItemsToStripeParams";
import {
type PayInvoiceResult,
type PaymentFailureMode,
payStripeInvoice,
} from "./payStripeInvoice";
import {
addStripeInvoiceLines,
createStripeInvoice,
finalizeStripeInvoice,
} from "./stripeInvoiceOps";
// ============================================
// Types
// ============================================
export type CreateAndPayInvoiceParams = {
stripeCli: Stripe;
stripeCusId: string;
stripeSubId?: string;
lineItems: LineItem[];
paymentMethod?: Stripe.PaymentMethod | null;
discounts?: { coupon: string }[];
description?: string;
onPaymentFailure?: PaymentFailureMode;
};
export type CreateAndPayInvoiceResult = PayInvoiceResult;
// ============================================
// Create and Pay Invoice
// ============================================
/**
* Full invoice workflow: create → add lines → finalize → pay
*/
export const createAndPayInvoice = async ({
stripeCli,
stripeCusId,
stripeSubId,
lineItems,
paymentMethod,
description,
onPaymentFailure = "return_url",
}: CreateAndPayInvoiceParams): Promise<CreateAndPayInvoiceResult> => {
// 1. Convert line items to Stripe lines
const stripeLines = lineItemsToStripeLines({ lineItems });
// 2. Create draft invoice
const invoice = await createStripeInvoice({
stripeCli,
stripeCusId,
stripeSubId,
description,
});
// 3. Add lines to invoice
await addStripeInvoiceLines({
stripeCli,
invoiceId: invoice.id,
lines: stripeLines,
});
// 4. Finalize invoice
const finalizedInvoice = await finalizeStripeInvoice({
stripeCli,
invoiceId: invoice.id,
});
// 5. If already paid (e.g. total <= 0), return early
if (finalizedInvoice.status === "paid") {
return {
paid: true,
invoice: finalizedInvoice,
};
}
// 6. Pay invoice
return payStripeInvoice({
stripeCli,
invoiceId: finalizedInvoice.id,
paymentMethod,
onFailure: onPaymentFailure,
});
};

View File

@@ -0,0 +1,34 @@
import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
/**
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
*/
export const lineItemToStripeLine = ({
lineItem,
}: {
lineItem: LineItem;
}): Stripe.InvoiceAddLinesParams.Line => {
const { finalAmount, description, context } = lineItem;
const { billingPeriod } = context;
return {
description,
amount: atmnToStripeAmount({ amount: finalAmount }),
period: {
start: msToSeconds(billingPeriod.start),
end: msToSeconds(billingPeriod.end),
},
};
};
/**
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
*/
export const lineItemsToStripeLines = ({
lineItems,
}: {
lineItems: LineItem[];
}): Stripe.InvoiceAddLinesParams.Line[] => {
return lineItems.map((lineItem) => lineItemToStripeLine({ lineItem }));
};

View File

@@ -0,0 +1,126 @@
import { ErrCode } from "@autumn/shared";
import type Stripe from "stripe";
import RecaseError from "@/utils/errorUtils.js";
// ============================================
// Types
// ============================================
export type PaymentFailureMode = "return_url" | "throw" | "void";
export type PayInvoiceResult = {
paid: boolean;
invoice: Stripe.Invoice;
hostedUrl?: string;
error?: Error;
};
export type PayStripeInvoiceParams = {
stripeCli: Stripe;
invoiceId: string;
paymentMethod?: Stripe.PaymentMethod | null;
onFailure?: PaymentFailureMode;
};
// ============================================
// Pay Invoice
// ============================================
export const payStripeInvoice = async ({
stripeCli,
invoiceId,
paymentMethod,
onFailure = "return_url",
}: PayStripeInvoiceParams): Promise<PayInvoiceResult> => {
// 1. Retrieve invoice to check status
let invoice = await stripeCli.invoices.retrieve(invoiceId);
// 2. Already paid - return success
if (invoice.status === "paid") {
return {
paid: true,
invoice,
};
}
// 3. No payment method - handle based on failure mode
if (!paymentMethod) {
return handlePaymentFailure({
stripeCli,
invoice,
onFailure,
error: new RecaseError({
message: "No payment method found",
code: ErrCode.CustomerHasNoPaymentMethod,
statusCode: 400,
}),
});
}
// 4. Attempt payment
try {
invoice = await stripeCli.invoices.pay(invoiceId, {
payment_method: paymentMethod.id,
});
return {
paid: true,
invoice,
};
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "Failed to pay invoice";
return handlePaymentFailure({
stripeCli,
invoice,
onFailure,
error: new RecaseError({
message: errMessage,
code: ErrCode.PayInvoiceFailed,
statusCode: 400,
}),
});
}
};
// ============================================
// Handle Payment Failure
// ============================================
const handlePaymentFailure = async ({
stripeCli,
invoice,
onFailure,
error,
}: {
stripeCli: Stripe;
invoice: Stripe.Invoice;
onFailure: PaymentFailureMode;
error: Error;
}): Promise<PayInvoiceResult> => {
switch (onFailure) {
case "throw":
throw error;
case "void":
try {
await stripeCli.invoices.voidInvoice(invoice.id!);
} catch (_voidError) {
// Silently fail void attempt
}
return {
paid: false,
invoice,
error,
};
default:
return {
paid: false,
invoice,
hostedUrl: invoice.hosted_invoice_url || undefined,
error,
};
}
};

View File

@@ -0,0 +1,83 @@
import type Stripe from "stripe";
// ============================================
// Create Invoice
// ============================================
export type CreateInvoiceParams = {
stripeCli: Stripe;
stripeCusId: string;
stripeSubId?: string;
currency?: string;
discounts?: { coupon: string }[];
collectionMethod?: "charge_automatically" | "send_invoice";
daysUntilDue?: number;
description?: string;
};
export const createStripeInvoice = async ({
stripeCli,
stripeCusId,
stripeSubId,
currency,
collectionMethod = "charge_automatically",
daysUntilDue,
description,
}: CreateInvoiceParams): Promise<Stripe.Invoice> => {
const invoice = await stripeCli.invoices.create({
customer: stripeCusId,
auto_advance: false,
...(stripeSubId ? { subscription: stripeSubId } : {}),
...(currency ? { currency } : {}),
...(description ? { description } : {}),
collection_method: collectionMethod,
days_until_due:
collectionMethod === "send_invoice" ? (daysUntilDue ?? 30) : undefined,
});
return invoice;
};
// ============================================
// Add Invoice Lines
// ============================================
export type AddInvoiceLinesParams = {
stripeCli: Stripe;
invoiceId: string;
lines: Stripe.InvoiceAddLinesParams.Line[];
};
export const addStripeInvoiceLines = async ({
stripeCli,
invoiceId,
lines,
}: AddInvoiceLinesParams): Promise<Stripe.Invoice> => {
const invoice = await stripeCli.invoices.addLines(invoiceId, {
lines,
});
return invoice;
};
// ============================================
// Finalize Invoice
// ============================================
export type FinalizeInvoiceParams = {
stripeCli: Stripe;
invoiceId: string;
autoAdvance?: boolean;
};
export const finalizeStripeInvoice = async ({
stripeCli,
invoiceId,
autoAdvance = false,
}: FinalizeInvoiceParams): Promise<Stripe.Invoice> => {
const invoice = await stripeCli.invoices.finalizeInvoice(invoiceId, {
auto_advance: autoAdvance,
});
return invoice;
};

View File

@@ -2,7 +2,6 @@ import {
type AttachBodyV1,
type AttachContext,
type CusProductActions,
type FullCustomer,
RELEVANT_STATUSES,
resolveAttachActions,
} from "@autumn/shared";
@@ -100,6 +99,7 @@ export const createAttachContext = async ({
sub,
testClockFrozenTime,
paymentMethod,
};
const actions = resolveAttachActions({
@@ -151,43 +151,4 @@ export const createAttachContext = async ({
// 7. If expiring a product, need to figure out carrying usage over
// 8. If expiring a product, need to figure out carrying rollovers over
// 9. Updating one time product?
// NEXT: execute the actions
const applyCusProductActions = async ({
ctx,
fullCus,
cusProductActions,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
cusProductActions: CusProductActions;
}) => {
// // 1. Execute new product actions
// for (const newProductAction of newProductActions) {
// // await executeNewProductAction({
// // ctx,
// // newProductAction,
// // });
// }
// // 2. Execute active cus product action
// if (ongoingCusProductAction) {
// await executeActiveCusProductAction({
// ctx,
// ongoingCusProductAction,
// });
// }
// // 3. Execute scheduled cus product action
// if (scheduledCusProductAction) {
// await executeScheduledCusProductAction({
// ctx,
// scheduledCusProductAction,
// });
// }
};
await applyCusProductActions({
ctx,
fullCus,
cusProductActions: actions,
});
};

View File

@@ -17,7 +17,7 @@ export const getStripeCusData = async ({
allowNoStripe?: boolean;
}) => {
if (allowNoStripe && !customer.processor?.id) {
return { stripeCus: undefined, paymentMethod: null, now: undefined };
return { stripeCus: undefined, paymentMethod: undefined, now: undefined };
}
const { logger, db, org, env } = ctx;
@@ -36,8 +36,14 @@ export const getStripeCusData = async ({
// let now = testClock ? testClock.frozen_time * 1000 : Date.now();
const now = testClock ? testClock.frozen_time * 1000 : undefined;
let paymentMethod = stripeCus.invoice_settings
?.default_payment_method as Stripe.PaymentMethod | null;
const invoiceSettingsPaymentMethod =
stripeCus.invoice_settings?.default_payment_method;
let paymentMethod: Stripe.PaymentMethod | undefined =
invoiceSettingsPaymentMethod &&
typeof invoiceSettingsPaymentMethod !== "string"
? invoiceSettingsPaymentMethod
: undefined;
if (!paymentMethod) {
const paymentMethods = await listCusPaymentMethods({
@@ -45,7 +51,7 @@ export const getStripeCusData = async ({
stripeId: stripeCus.id,
});
paymentMethod = paymentMethods.length ? paymentMethods[0] : null;
paymentMethod = paymentMethods.length ? paymentMethods[0] : undefined;
}
return { stripeCus, paymentMethod, now };

View File

@@ -1,13 +1,12 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
FullCustomerEntitlement,
Rollover,
RolloverConfig,
type FullCustomerEntitlement,
type Rollover,
rollovers,
} from "@autumn/shared";
import { and, eq, gte, inArray } from "drizzle-orm";
import { performMaximumClearing } from "./rolloverUtils.js";
import { buildConflictUpdateColumns } from "@/db/dbUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { performMaximumClearing } from "./rolloverUtils.js";
export class RolloverService {
static async update({
@@ -79,28 +78,19 @@ export class RolloverService {
static async insert({
db,
rows,
// rolloverConfig,
fullCusEnt,
// cusEntID,
// entityMode,
}: {
db: DrizzleCli;
rows: Rollover[];
// rolloverConfig: RolloverConfig;
fullCusEnt: FullCustomerEntitlement;
// cusEntID: string;
// entityMode: boolean;
}) {
if (rows.length === 0) return {};
await db
.insert(rollovers)
.values(rows as any)
.returning();
await db.insert(rollovers).values(rows).returning();
let curRollovers = [...fullCusEnt.rollovers, ...rows];
let { toDelete, toUpdate } = performMaximumClearing({
const { toDelete, toUpdate } = performMaximumClearing({
rows: curRollovers as Rollover[],
cusEnt: fullCusEnt,
});
@@ -116,7 +106,7 @@ export class RolloverService {
// Return latest rollovers...?
curRollovers = curRollovers.filter((r) => toDelete.includes(r.id));
curRollovers = curRollovers.map((r) => {
let updatedRow = toUpdate.find((u) => u.id === r.id);
const updatedRow = toUpdate.find((u) => u.id === r.id);
if (updatedRow) {
return updatedRow;
}

View File

@@ -82,16 +82,10 @@ export const calculateNextExpiry = (
export function performMaximumClearing({
rows,
// rolloverConfig,
cusEnt,
// cusEntID,
// entityMode,
}: {
rows: Rollover[];
// rolloverConfig: RolloverConfig;
cusEnt: FullCustomerEntitlement;
// cusEntID: string;
// entityMode: boolean;
}) {
const rolloverConfig = cusEnt.entitlement.rollover;

View File

@@ -120,13 +120,6 @@ export const adjustAllowance = async ({
stripeCli,
});
// const sub = await getUsageBasedSub({
// db,
// stripeCli,
// subIds: cusProduct.subscription_ids!,
// feature: affectedFeature,
// });
if (!sub) {
logger.error("adjustAllowance: no usage-based sub found");
return { newReplaceables: null, invoice: null, deletedReplaceables: null };

View File

@@ -1,18 +1,12 @@
import {
BillingInterval,
BillingType,
type FullProduct,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { filterByBillingType } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import {
BillingType,
FullProduct,
UsagePriceConfig,
FullCusProduct,
BillingInterval,
} from "@autumn/shared";
import Stripe from "stripe";
import { ExtendedRequest } from "../models/Request.js";
import { cusProductToPrices } from "@autumn/shared";
import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js";
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { logger } from "@/external/logtail/logtailUtils.js";
export const addContUsePricesToSub = async ({
stripe,
@@ -41,7 +35,7 @@ export const addContUsePricesToSub = async ({
for (const usagePrice of usagePrices) {
const config = usagePrice.config as UsagePriceConfig;
const latestSub = await stripe.subscriptions.retrieve(sub.id);
let subItem = findStripeItemForPrice({
const subItem = findStripeItemForPrice({
price: usagePrice,
stripeItems: latestSub.items.data,
});
@@ -65,62 +59,3 @@ export const addContUsePricesToSub = async ({
logger.info(`Successfully added ${config.feature_id} to sub`);
}
};
export const addUsagePricesToSub = async ({
req,
stripeCli,
stripeSub,
cusProduct,
}: {
req: ExtendedRequest;
stripeCli: Stripe;
stripeSub: Stripe.Subscription;
cusProduct: FullCusProduct;
}) => {
const { features } = req;
const prices = cusProductToPrices({
cusProduct,
billingType: BillingType.UsageInArrear,
});
for (const price of prices) {
const feature = priceToFeature({ price, features: features });
const usageBasedSub = await getUsageBasedSub({
stripeCli,
subIds: cusProduct.subscription_ids || [],
feature: feature!,
db: req.db,
});
let subItem = findStripeItemForPrice({
price,
stripeItems: stripeSub.items.data,
});
let config = price.config as UsagePriceConfig;
if (subItem) {
logger.info(`Sub already has price for ${config.feature_id}`);
continue;
}
logger.info(`Adding ${config.feature_id} to sub ${stripeSub.id}`);
let stripePrice = await stripeCli.prices.retrieve(
price.config.stripe_price_id!,
);
if (stripePrice.recurring?.usage_type !== "metered") {
logger.info(
`Skipping ${config.feature_id} because it's not a metered price`,
);
continue;
}
await stripeCli.subscriptionItems.create({
subscription: stripeSub.id,
price: price.config.stripe_price_id!,
proration_behavior: "none",
});
logger.info(`Successfully added ${config.feature_id} to sub`);
}
};

View File

@@ -1,8 +1,13 @@
import { beforeAll, describe } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import {
ApiVersion,
CouponDurationType,
type CreateReward,
RewardType,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { createReward } from "@tests/utils/productUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
@@ -16,7 +21,6 @@ import {
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { advanceTestClock } from "../../src/utils/scriptUtils/testClockUtils";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0";
const freeProd = constructProduct({
@@ -129,6 +133,24 @@ const entities = [
},
];
// 50% off reward that only applies to pro product
const rewardId = "50_percent_off";
const promoCode = "50OFF";
const reward: CreateReward = {
id: rewardId,
name: "50% Off Pro",
type: RewardType.PercentageDiscount,
promo_codes: [{ code: promoCode }],
discount_config: {
discount_value: 50, // 50% off
duration_type: CouponDurationType.Forever,
duration_value: 0,
should_rollover: false,
apply_to_all: false, // Only applies to specific product
price_ids: [], // Will be populated when creating reward with productId
},
};
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
const customerId = "temp";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -147,6 +169,15 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
prefix: customerId,
});
await createReward({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
autumn: autumnV1,
reward,
// productId: pro.id,
});
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
@@ -156,6 +187,7 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
quantity: 300,
},
],
// reward: rewardId,
});
await autumnV1.entities.create(customerId, entities);
@@ -172,15 +204,27 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
value: 1000,
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: result.testClockId,
advanceTo: toUnix({
year: 2025,
month: 12,
day: 22,
}),
});
// const customer = await CusService.getFull({
// db: ctx.db,
// idOrInternalId: customerId,
// orgId: ctx.org.id,
// env: ctx.env,
// });
// await attachFailedPaymentMethod({
// stripeCli: ctx.stripeCli,
// customer,
// });
// await advanceTestClock({
// stripeCli: ctx.stripeCli,
// testClockId: result.testClockId,
// advanceTo: toUnix({
// year: 2025,
// month: 12,
// day: 22,
// }),
// });
});
return;
});

View File

@@ -23,3 +23,4 @@ export const createMockEntity = ({
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
});

View File

@@ -138,6 +138,16 @@ export const createReward = async ({
);
reward.discount_config!.price_ids = usagePrices?.map((price) => price.id);
} else if (productId) {
const fullProduct = await ProductService.getFull({
db,
orgId,
env,
idOrInternalId: productId,
});
reward.discount_config!.price_ids =
fullProduct?.prices.map((p) => p.id) ?? [];
}
try {

View File

@@ -104,6 +104,8 @@ export * from "./models/billingModels/initFullCusProductContext.js";
export * from "./models/billingModels/invoicingModels/lineItem.js";
// Billing Models
export * from "./models/billingModels/newProductAction.js";
export * from "./models/billingModels/stripeAdapterModels/stripeDiscountWithCoupon.js";
export * from "./models/billingModels/stripeAdapterModels/stripeItemSpec.js";
export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationModels.js";

View File

@@ -16,6 +16,7 @@ export type AttachContext = {
sub?: Stripe.Subscription;
schedule?: Stripe.SubscriptionSchedule;
testClockFrozenTime?: number; // in milliseconds since epoch
paymentMethod?: Stripe.PaymentMethod;
};
// stripeCli: Stripe;

View File

@@ -1,15 +1,18 @@
import z from "zod/v4";
import {
type OngoingCusProductAction,
OngoingCusProductActionSchema,
type ScheduledCusProductAction,
ScheduledCusProductActionSchema,
} from "../attachModels/cusProductActions";
import {
EnrichedNewProductActionSchema,
type NewProductAction,
NewProductActionSchema,
} from "./newProductAction";
import {
type OngoingCusProductAction,
OngoingCusProductActionSchema,
} from "./ongoingCusProductAction";
import {
type ScheduledCusProductAction,
ScheduledCusProductActionSchema,
} from "./scheduledCusProductAction";
export interface CusProductActions {
ongoingCusProductAction?: OngoingCusProductAction;

View File

@@ -1,11 +1,49 @@
import type { Feature } from "../../featureModels/featureModels";
import type { Price } from "../../productModels/priceModels/priceModels";
import type { LineItemContext } from "./lineItemContext";
import { z } from "zod/v4";
import { LineItemContextSchema } from "./lineItemContext";
export type LineItem = {
amount: number;
description: string;
price: Price;
feature?: Feature; // Optional - fixed prices don't have features
context: LineItemContext;
};
export const LineItemDiscountSchema = z.object({
amountOff: z.number(),
percentOff: z.number().optional(),
stripeCouponId: z.string().optional(),
});
export const LineItemSchema = z
.object({
amount: z.number(),
discounts: z.array(LineItemDiscountSchema).default([]),
finalAmount: z.number().default(0),
description: z.string(),
context: LineItemContextSchema,
stripePriceId: z.string().optional(),
stripeProductId: z.string().optional(),
})
.transform((data) => {
return {
...data,
finalAmount: data.amount,
};
});
export type LineItemCreate = z.input<typeof LineItemSchema>;
export type LineItem = z.infer<typeof LineItemSchema>;
export type LineItemDiscount = z.infer<typeof LineItemDiscountSchema>;
// export type LineItem = {
// amount: number;
// discounts: LineItemDiscount[];
// finalAmount: number;
// description: string;
// price: Price;
// feature?: Feature; // Optional - fixed prices don't have features
// context: LineItemContext;
// // Stripe fields
// stripePriceId?: string;
// stripeProductId?: string;
// };

View File

@@ -1,12 +1,24 @@
export type BillingPeriod = {
start: number;
end: number;
};
import { z } from "zod/v4";
import { FeatureSchema } from "../../featureModels/featureModels";
import { PriceSchema } from "../../productModels/priceModels/priceModels";
import { ProductSchema } from "../../productModels/productModels";
export type LineItemContext = {
productName: string;
billingPeriod: BillingPeriod;
direction: "charge" | "refund";
now: number;
billingTiming: "in_arrear" | "in_advance";
};
export const BillingPeriodSchema = z.object({
start: z.number(),
end: z.number(),
});
export const LineItemContextSchema = z.object({
price: PriceSchema,
product: ProductSchema,
feature: FeatureSchema.optional(),
currency: z.string(),
billingPeriod: BillingPeriodSchema,
direction: z.enum(["charge", "refund"]),
now: z.number(),
billingTiming: z.enum(["in_arrear", "in_advance"]),
});
export type BillingPeriod = z.infer<typeof BillingPeriodSchema>;
export type LineItemContext = z.infer<typeof LineItemContextSchema>;

View File

@@ -6,16 +6,6 @@ export const OngoingCusProductActionSchema = z.object({
action: z.literal(["expire", "cancel", "uncancel"]),
cusProduct: FullCusProductSchema,
});
// What happens to any SCHEDULED cus product
export const ScheduledCusProductActionSchema = z.object({
action: z.literal("delete"),
cusProduct: FullCusProductSchema,
});
export type OngoingCusProductAction = z.infer<
typeof OngoingCusProductActionSchema
>;
export type ScheduledCusProductAction = z.infer<
typeof ScheduledCusProductActionSchema
>;

View File

@@ -0,0 +1,12 @@
import z from "zod/v4";
import { FullCusProductSchema } from "../cusProductModels/cusProductModels";
// What happens to any SCHEDULED cus product
export const ScheduledCusProductActionSchema = z.object({
action: z.literal("delete"),
cusProduct: FullCusProductSchema,
});
export type ScheduledCusProductAction = z.infer<
typeof ScheduledCusProductActionSchema
>;

View File

@@ -0,0 +1,5 @@
import type Stripe from "stripe";
export type StripeDiscountWithCoupon = Stripe.Discount & {
source: { coupon: Stripe.Coupon };
};

View File

@@ -0,0 +1,7 @@
import type { Price } from "../../productModels/priceModels/priceModels";
export type StripeItemSpec = {
stripePriceId: string; // stripe price ID
quantity?: number;
autumnPrice?: Price;
};

View File

@@ -4,7 +4,8 @@ export * from "./intervalUtils/intervalArithmetic";
export * from "./invoicingUtils/cusProductToArrearLineItems";
export * from "./invoicingUtils/cusProductToLineItems";
export * from "./invoicingUtils/lineItemBuilders/consumablePriceToLineItem";
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem";
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount";
export * from "./invoicingUtils/prorationUtils/applyProration";

View File

@@ -1,21 +1,25 @@
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
import type { Organization } from "../../../models/orgModels/orgTable";
import { cusPriceToCusEntWithCusProduct } from "../../cusPriceUtils/convertCusPriceUtils";
import { orgToCurrency } from "../../orgUtils/convertOrgUtils";
import { isConsumablePrice } from "../../productUtils/priceUtils/classifyPriceUtils";
import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod";
import { consumablePriceToLineItem } from "./lineItemBuilders/consumablePriceToLineItem";
import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem";
export const cusProductToArrearLineItems = ({
cusProduct,
billingCycleAnchor,
testClockFrozenTime,
org,
}: {
cusProduct: FullCusProduct;
billingCycleAnchor: number;
testClockFrozenTime?: number;
org: Organization;
}) => {
const lineItems: LineItem[] = [];
let lineItems: LineItem[] = [];
const productName = cusProduct.product.name;
const now = testClockFrozenTime ?? Date.now();
@@ -44,23 +48,21 @@ export const cusProductToArrearLineItems = ({
}
const context: LineItemContext = {
productName,
price,
product: cusProduct.product,
feature: cusEnt.entitlement.feature,
billingPeriod,
direction: "charge",
billingTiming: "in_arrear",
now,
currency: orgToCurrency({ org }),
};
lineItems.push(consumablePriceToLineItem({ cusEnt, context }));
lineItems.push(usagePriceToLineItem({ cusEnt, context }));
}
console.log(
`arrear line items: `,
lineItems.map((item) => ({
amount: item.amount,
description: item.description,
})),
);
lineItems = lineItems.filter((item) => item.amount !== 0);
return lineItems;
};

View File

@@ -1,17 +1,17 @@
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
import type { Organization } from "../../../models/orgModels/orgTable";
import { addCusProductToCusEnt } from "../../cusEntUtils/cusEntUtils";
import { cusPriceToCusEnt } from "../../cusPriceUtils/convertCusPriceUtils";
import { isPrepaidPrice } from "../../productUtils/priceUtils";
import { orgToCurrency } from "../../orgUtils/convertOrgUtils";
import {
isAllocatedPrice,
isConsumablePrice,
isFixedPrice,
} from "../../productUtils/priceUtils/classifyPriceUtils";
import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod";
import { consumablePriceToLineItem } from "./lineItemBuilders/consumablePriceToLineItem";
import { fixedPriceToLineItem } from "./lineItemBuilders/fixedPriceToLineItem";
import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem";
// TODO: import these once implemented
// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
@@ -32,22 +32,21 @@ export const cusProductToLineItems = ({
testClockFrozenTime,
billingCycleAnchor,
direction,
org,
}: {
cusProduct: FullCusProduct;
testClockFrozenTime?: number;
billingCycleAnchor: number;
direction: "charge" | "refund";
org: Organization;
}): LineItem[] => {
const lineItems: LineItem[] = [];
const productName = cusProduct.product.name;
let lineItems: LineItem[] = [];
const now = testClockFrozenTime ?? Date.now();
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
const { interval, interval_count: intervalCount } = price.config;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchor,
@@ -57,17 +56,20 @@ export const cusProductToLineItems = ({
// Build line item context
const context: LineItemContext = {
productName,
price,
product: cusProduct.product,
feature: undefined,
billingPeriod,
direction,
billingTiming: "in_advance",
now,
currency: orgToCurrency({ org }),
};
if (isFixedPrice(price)) {
lineItems.push(
fixedPriceToLineItem({
price,
context,
quantity: cusProduct.quantity ?? 1,
}),
@@ -75,11 +77,15 @@ export const cusProductToLineItems = ({
continue;
}
if (isConsumablePrice(price)) continue;
const cusEnt = cusPriceToCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
context.feature = cusEnt?.entitlement.feature;
if (!cusEnt) {
throw new Error(
`[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`,
@@ -91,64 +97,23 @@ export const cusProductToLineItems = ({
cusProduct,
});
if (isPrepaidPrice({ price })) {
lineItems.push(
prepaidPriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
if (isAllocatedPrice(price)) {
lineItems.push(
consumablePriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
// if (isFixedPrice(price)) {
// item = fixedPriceToLineItem({
// price,
// productName,
// currency,
// billingPeriod,
// now,
// quantity: cusProduct.quantity ?? 1,
// });
// }
// TODO: Add prepaid and allocated once implemented
// if (isPrepaidPrice(price)) {
// const cusEnt = findCusEntForPrice({ cusProduct, price });
// const overage = cusEntToTotalOverage({ cusEnt });
// item = prepaidPriceToLineItem({ price, overage, billingPeriod });
// }
// if (isAllocatedPrice(price)) {
// const cusEnt = findCusEntForPrice({ cusProduct, price });
// const quantity = cusEnt?.balance ?? 0;
// item = allocatedPriceToLineItem({ price, quantity, billingPeriod });
// }
// if (item) {
// // Negate amount for credits (OLD product)
// if (direction === "credit") {
// item = lineItemToCredit(item);
// }
// lineItems.push(item);
// }
lineItems.push(
usagePriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
console.log(
"Line items:",
lineItems.map((item) => ({
amount: item.amount,
description: item.description,
})),
);
// console.log(
// "Line items:",
// lineItems.map((item) => ({
// amount: item.amount,
// description: item.description,
// })),
// );
lineItems = lineItems.filter((item) => item.amount !== 0);
return lineItems;
};

View File

@@ -16,14 +16,14 @@ export const fixedPriceToDescription = ({
}): string => {
const config = price.config as FixedPriceConfig;
const { productName } = context;
const { product } = context;
// biome-ignore lint/correctness/noUnusedVariables: Might be used in the future
const amount = formatAmount({ currency, amount: config.amount });
let description = `${productName} - Base Price`;
let description = `${product.name} - Base Price`;
if (isOneOffPrice(price)) {
if (!isOneOffPrice(price)) {
const periodDescription = lineItemToPeriodDescription({
context,
});
@@ -31,5 +31,9 @@ export const fixedPriceToDescription = ({
description = `${description} (${periodDescription})`;
}
if (context.direction === "refund") {
description = `Unused ${description}`;
}
return description;
};

View File

@@ -1,6 +1,5 @@
import { isSameDay } from "date-fns";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import { formatMs, formatMsToDate } from "../../../common/formatUtils";
import { formatMsToDate } from "../../../common/formatUtils";
export const lineItemToPeriodDescription = ({
context,
@@ -14,9 +13,9 @@ export const lineItemToPeriodDescription = ({
const periodStart = billingTiming === "in_arrear" ? billingPeriod.start : now;
const periodEnd = billingTiming === "in_arrear" ? now : billingPeriod.end;
if (isSameDay(periodStart, periodEnd)) {
return `from ${formatMs(periodStart, { excludeSeconds: true })} to ${formatMs(periodEnd, { excludeSeconds: true })}`;
}
// if (isSameDay(periodStart, periodEnd)) {
// return `from ${formatMs(periodStart, { excludeSeconds: true })} to ${formatMs(periodEnd, { excludeSeconds: true })}`;
// }
return `from ${formatMsToDate(periodStart)} to ${formatMsToDate(periodEnd)}`;
};

View File

@@ -25,8 +25,8 @@ export const usagePriceToLineDescription = ({
billingUnits,
});
const { productName } = context;
let description = `${productName} - ${featureUsageDescription}`;
const { product } = context;
let description = `${product.name} - ${featureUsageDescription}`;
if (!isOneOffPrice(price)) {
const periodDescription = lineItemToPeriodDescription({
@@ -36,6 +36,10 @@ export const usagePriceToLineDescription = ({
description = `${description} (${periodDescription})`;
}
if (context.direction === "refund") {
description = `Unused ${description}`;
}
// if (billingPeriod) {
// description = `${description} (${billingPeriodToDescription(billingPeriod)})`;
// }

View File

@@ -0,0 +1,48 @@
// shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts
import {
type LineItem,
type LineItemCreate,
LineItemSchema,
} from "../../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import { applyProration } from "../prorationUtils/applyProration";
export const buildLineItem = ({
context,
amount,
description,
stripePriceId,
stripeProductId,
shouldProrate = true,
}: {
context: LineItemContext;
amount: number;
description: string;
stripePriceId?: string;
stripeProductId?: string;
shouldProrate?: boolean;
}): LineItem => {
// 1. Apply proration if needed
if (shouldProrate) {
amount = applyProration({
now: context.now,
billingPeriod: context.billingPeriod,
amount,
});
}
// 2. Handle refund direction
if (context.direction === "refund") {
amount = -amount;
}
// 3. Return LineItem
return LineItemSchema.parse({
amount,
description,
context,
stripePriceId,
stripeProductId,
} satisfies LineItemCreate);
};

View File

@@ -1,55 +0,0 @@
import { InternalError } from "../../../../api/errors/base/InternalError";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage";
import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
// import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
/**
* Creates a line item for a consumable (UsageInArrear) price.
* Returns null if there's no overage to charge.
*/
export const consumablePriceToLineItem = ({
cusEnt,
context,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
}) => {
// 1. Get cus price
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) {
throw new InternalError({
message: `[consumablePriceToLineItem] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
// 1. Get usage / overage
const invoiceUsage = cusEntToInvoiceUsage({ cusEnt });
const invoiceOverage = cusEntToInvoiceOverage({ cusEnt });
// 2. Get amount
const amount = priceToLineAmount({
price: cusPrice.price,
overage: invoiceOverage,
});
// 4. Generate description
const description = usagePriceToLineDescription({
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
usage: invoiceUsage,
context,
});
return {
amount,
description,
price: cusPrice.price,
context,
};
};

View File

@@ -1,51 +1,40 @@
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { fixedPriceToDescription } from "../descriptionUtils/fixedPriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
import { applyProration } from "../prorationUtils/applyProration";
import { buildLineItem } from "./buildLineItem";
/**
* Creates a line item for a fixed price.
* Returns positive amount - caller uses lineItemToCredit() for refunds.
*/
export const fixedPriceToLineItem = ({
price,
currency,
quantity = 1,
context,
}: {
price: Price;
currency?: string;
quantity?: number;
context: LineItemContext;
}): LineItem => {
// 1. Calculate base amount
let amount = priceToLineAmount({ price, multiplier: quantity });
const { price, product } = context;
// 2. Apply proration
const { now, billingPeriod } = context;
amount = applyProration({
now,
amount,
billingPeriod: billingPeriod,
});
if (context.direction === "refund") {
amount = -amount;
}
// 3. Generate description
const amount = priceToLineAmount({ price, multiplier: quantity });
const description = fixedPriceToDescription({
price,
currency,
context,
});
return {
const stripePriceId = price.config.stripe_price_id ?? undefined;
const stripeProductId =
price.config.stripe_product_id || product.processor?.id || undefined;
return buildLineItem({
context,
amount,
description,
price,
context,
};
stripePriceId,
stripeProductId,
});
};

View File

@@ -1,66 +0,0 @@
import { InternalError } from "../../../../api/errors";
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToPrepaidQuantity } from "../../../cusEntUtils/balanceUtils/cusEntToPrepaidQuantity";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
import { applyProration } from "../prorationUtils/applyProration";
/**
* Creates a line item for a fixed price.
* Returns positive amount - caller uses lineItemToCredit() for refunds.
*/
export const prepaidPriceToLineItem = ({
cusEnt,
context,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
}): LineItem => {
const { now, billingPeriod } = context;
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) {
throw new InternalError({
message: `[prepaidPriceToLineItem] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
// 1. Get prepaid quantity
const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt });
// 2. Get amount
let amount = priceToLineAmount({
price: cusPrice.price,
overage: prepaidQuantity,
});
if (context.direction === "refund") {
amount = -amount;
}
// 3. Apply proration
amount = applyProration({
now,
billingPeriod,
amount,
});
// 4. Generate description
const description = usagePriceToLineDescription({
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
usage: prepaidQuantity,
context,
});
return {
amount,
description,
price: cusPrice.price,
context,
};
};

View File

@@ -0,0 +1,85 @@
import { InternalError } from "../../../../api/errors/base/InternalError";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToPrepaidQuantity } from "../../../cusEntUtils/balanceUtils/cusEntToPrepaidQuantity";
import { cusEntToStripeIds } from "../../../cusEntUtils/convertCusEntUtils/cusEntToStripeIds";
import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage";
import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { isPrepaidPrice } from "../../../productUtils/priceUtils";
import { isConsumablePrice } from "../../../productUtils/priceUtils/classifyPriceUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
import { buildLineItem } from "./buildLineItem";
export const usagePriceToLineItem = ({
cusEnt,
context,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
const { feature } = context;
if (!feature) {
throw new InternalError({
message: `[usagePriceToLineItem] No feature found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
if (!cusPrice) {
throw new InternalError({
message: `[usagePriceToLineItem] No cus price found for cus ent (feature: ${feature.id})`,
});
}
const price = cusPrice.price;
// 1. Get overage
let overage = 0;
if (isPrepaidPrice({ price: cusPrice.price })) {
overage = cusEntToPrepaidQuantity({ cusEnt });
} else {
overage = cusEntToInvoiceOverage({ cusEnt });
}
// 2. Get usage
let usage = 0;
if (isPrepaidPrice({ price: cusPrice.price })) {
usage = cusEntToPrepaidQuantity({ cusEnt });
} else {
usage = cusEntToInvoiceUsage({ cusEnt });
}
// 3. Generate description
const description = usagePriceToLineDescription({
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
usage,
context,
});
// 4. Get amount
const amount = priceToLineAmount({
price,
overage,
});
// 5. Get stripe price / product IDs
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
// 6. Should prorate: don't if consumable price
const shouldProrate = !isConsumablePrice(price);
return buildLineItem({
context,
amount,
description,
stripePriceId,
stripeProductId,
shouldProrate,
});
};

View File

@@ -14,7 +14,7 @@ export const applyProration = ({
const denom = new Decimal(end).minus(start);
const num = new Decimal(now).minus(start);
const num = new Decimal(end).minus(now);
return num.div(denom).mul(amount).toNumber();
};

View File

@@ -41,3 +41,7 @@ export const secondsToMs = (
return seconds * 1000;
};
export const msToSeconds = (ms: number): number => {
return Math.floor(ms / 1000);
};

View File

@@ -6,6 +6,7 @@ import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels
import { FeatureType } from "../../models/featureModels/featureEnums";
import { AllowanceType } from "../../models/productModels/entModels/entModels";
import { cusEntToCusPrice } from "../productUtils/convertUtils";
import { isAllocatedPrice } from "../productUtils/priceUtils/classifyPriceUtils";
import { notNullish } from "../utils";
export const isUnlimitedCusEnt = (cusEnt: FullCustomerEntitlement) => {
@@ -40,3 +41,9 @@ export const isBooleanCusEnt = ({
}) => {
return cusEnt.entitlement.feature.type === FeatureType.Boolean;
};
export const isAllocatedCusEnt = (cusEnt: FullCusEntWithFullCusProduct) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
return cusPrice && isAllocatedPrice(cusPrice.price);
};

View File

@@ -111,52 +111,8 @@ export const cusEntToIncludedUsage = ({
}
return total;
// if (rollover) {
// total = new Decimal(total)
// .add(rollover.balance)
// .add(rollover.usage)
// .toNumber();
// }
};
// NEW CUS ENT UTILS
// export const cusEntToGrantedBalance = ({
// cusEnt,
// entityId,
// withRollovers = false,
// }: {
// cusEnt: FullCusEntWithFullCusProduct;
// entityId?: string;
// withRollovers?: boolean;
// }) => {
// const rollover = getRolloverFields({
// cusEnt,
// entityId,
// });
// const { count: entityCount } = getCusEntBalance({
// cusEnt,
// entityId,
// });
// const grantedBalance = cusEnt.entitlement.allowance || 0;
// const total = new Decimal(grantedBalance)
// .mul(cusEnt.customer_product.quantity ?? 1)
// .mul(entityCount)
// .toNumber();
// if (withRollovers && rollover) {
// return new Decimal(total)
// .add(rollover.balance)
// .add(rollover.usage)
// .toNumber();
// }
// return total;
// };
export const apiBalanceToBreakdownKey = ({
breakdown,
}: {

View File

@@ -0,0 +1,26 @@
import { InternalError } from "../../../api/errors/base/InternalError";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToCusPrice } from "../../productUtils/convertUtils";
export const cusEntToStripeIds = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) {
throw new InternalError({
message: `[cusEntToStripeIds] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
const stripePriceId = cusPrice.price.config.stripe_price_id;
const stripeProductId =
cusPrice.price.config.stripe_product_id ||
cusEnt.customer_product.product.processor?.id;
return {
stripePriceId: stripePriceId ?? undefined,
stripeProductId: stripeProductId ?? undefined,
};
};

View File

@@ -142,3 +142,17 @@ export const isProductAlreadyEnabled = ({
return false;
});
};
export const filterCusProductsBySubId = ({
cusProducts,
subId,
}: {
cusProducts: FullCusProduct[];
subId?: string;
}): FullCusProduct[] => {
if (!subId) return [];
return cusProducts.filter((cp: FullCusProduct) =>
cp.subscription_ids?.includes(subId),
);
};

View File

@@ -1,5 +1,8 @@
// Billing utils
export * from "../models/billingModels/ongoingCusProductAction";
export * from "../models/billingModels/scheduledCusProductAction";
export * from "./billingUtils/resolveAttachUtils/getUncancelAttachActions.js";
export * from "./billingUtils/resolveAttachUtils/resolveAttachActions.js";
export * from "./billingUtils/resolveAttachUtils/resolveNewProductTiming.js";

View File

@@ -7,3 +7,7 @@ export const orgToInStatuses = ({ org }: { org: Organization }) => {
}
return [CusProductStatus.Active];
};
export const orgToCurrency = ({ org }: { org: Organization }) => {
return org.default_currency || "usd";
};