wip
This commit is contained in:
49
server/src/external/stripe/priceToStripeItem/consumablePriceToStripeItem.ts
vendored
Normal file
49
server/src/external/stripe/priceToStripeItem/consumablePriceToStripeItem.ts
vendored
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export const priceToOneOffAndTiered = ({
|
||||
);
|
||||
}
|
||||
return {
|
||||
price: undefined,
|
||||
price_data: {
|
||||
product: config.stripe_product_id
|
||||
? config.stripe_product_id
|
||||
|
||||
31
server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts
vendored
Normal file
31
server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
// });
|
||||
// }
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -23,3 +23,4 @@ export const createMockEntity = ({
|
||||
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AttachContext = {
|
||||
sub?: Stripe.Subscription;
|
||||
schedule?: Stripe.SubscriptionSchedule;
|
||||
testClockFrozenTime?: number; // in milliseconds since epoch
|
||||
paymentMethod?: Stripe.PaymentMethod;
|
||||
};
|
||||
|
||||
// stripeCli: Stripe;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
// };
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
12
shared/models/billingModels/scheduledCusProductAction.ts
Normal file
12
shared/models/billingModels/scheduledCusProductAction.ts
Normal 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
|
||||
>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export type StripeDiscountWithCoupon = Stripe.Discount & {
|
||||
source: { coupon: Stripe.Coupon };
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Price } from "../../productModels/priceModels/priceModels";
|
||||
|
||||
export type StripeItemSpec = {
|
||||
stripePriceId: string; // stripe price ID
|
||||
quantity?: number;
|
||||
autumnPrice?: Price;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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)})`;
|
||||
// }
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -41,3 +41,7 @@ export const secondsToMs = (
|
||||
|
||||
return seconds * 1000;
|
||||
};
|
||||
|
||||
export const msToSeconds = (ms: number): number => {
|
||||
return Math.floor(ms / 1000);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,3 +7,7 @@ export const orgToInStatuses = ({ org }: { org: Organization }) => {
|
||||
}
|
||||
return [CusProductStatus.Active];
|
||||
};
|
||||
|
||||
export const orgToCurrency = ({ org }: { org: Organization }) => {
|
||||
return org.default_currency || "usd";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user