diff --git a/server/src/external/stripe/priceToStripeItem/consumablePriceToStripeItem.ts b/server/src/external/stripe/priceToStripeItem/consumablePriceToStripeItem.ts new file mode 100644 index 000000000..52e601395 --- /dev/null +++ b/server/src/external/stripe/priceToStripeItem/consumablePriceToStripeItem.ts @@ -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, + }; +}; diff --git a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts index 1637c0604..6360edc2f 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts @@ -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, }; }; diff --git a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts index be636c32a..309491d9b 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts @@ -34,6 +34,7 @@ export const priceToOneOffAndTiered = ({ ); } return { + price: undefined, price_data: { product: config.stripe_product_id ? config.stripe_product_id diff --git a/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts b/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts new file mode 100644 index 000000000..286c53ffe --- /dev/null +++ b/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts @@ -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; +}; diff --git a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts index 1978eeda2..b0530b3ee 100644 --- a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts @@ -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(); } diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 85b3891b2..c3d3528a2 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -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); } diff --git a/server/src/internal/billing/billingUtils/applyCusProductActions/applyCusProductActions.ts b/server/src/internal/billing/billingUtils/applyCusProductActions/applyCusProductActions.ts new file mode 100644 index 000000000..cf9450f5b --- /dev/null +++ b/server/src/internal/billing/billingUtils/applyCusProductActions/applyCusProductActions.ts @@ -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, + }); + } +}; diff --git a/server/src/internal/billing/billingUtils/applyCusProductActions/applyNewProductAction.ts b/server/src/internal/billing/billingUtils/applyCusProductActions/applyNewProductAction.ts deleted file mode 100644 index 25d42b6a4..000000000 --- a/server/src/internal/billing/billingUtils/applyCusProductActions/applyNewProductAction.ts +++ /dev/null @@ -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 }); - } -}; diff --git a/server/src/internal/billing/billingUtils/applyCusProductActions/applyOngoingCusProductAction.ts b/server/src/internal/billing/billingUtils/applyCusProductActions/applyOngoingCusProductAction.ts new file mode 100644 index 000000000..b12badf8f --- /dev/null +++ b/server/src/internal/billing/billingUtils/applyCusProductActions/applyOngoingCusProductAction.ts @@ -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, + }, + }); + } +}; diff --git a/server/src/internal/billing/billingUtils/applyCusProductActions/insertNewCusProducts.ts b/server/src/internal/billing/billingUtils/applyCusProductActions/insertNewCusProducts.ts new file mode 100644 index 000000000..6ad935329 --- /dev/null +++ b/server/src/internal/billing/billingUtils/applyCusProductActions/insertNewCusProducts.ts @@ -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, + }); + } +}; diff --git a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts index 835ca7fb0..beac27498 100644 --- a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts +++ b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts @@ -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; }; diff --git a/server/src/internal/billing/billingUtils/executeActiveCusProductAction.ts b/server/src/internal/billing/billingUtils/executeActiveCusProductAction.ts deleted file mode 100644 index 916c917da..000000000 --- a/server/src/internal/billing/billingUtils/executeActiveCusProductAction.ts +++ /dev/null @@ -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, -// }, -// }); -// } -// }; diff --git a/server/src/internal/billing/billingUtils/getAttachSub.ts b/server/src/internal/billing/billingUtils/getAttachSub.ts index 8d244e79b..e923278e8 100644 --- a/server/src/internal/billing/billingUtils/getAttachSub.ts +++ b/server/src/internal/billing/billingUtils/getAttachSub.ts @@ -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 }; }; diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts index 5957afa1d..2d857f55d 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts @@ -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], diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts new file mode 100644 index 000000000..b98b00a9b --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts @@ -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(); + + // 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, + }; + }); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts new file mode 100644 index 000000000..51f969180 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts @@ -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, + }; + }); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts new file mode 100644 index 000000000..694417dc0 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts @@ -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; +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts new file mode 100644 index 000000000..520d38064 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts @@ -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; +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts new file mode 100644 index 000000000..a6be72c55 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts @@ -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; +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts b/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts new file mode 100644 index 000000000..9a3b0c93b --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts @@ -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 => { + const targetItems = new Map(); + + 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; + 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; + 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; + 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 }); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToSubItems.ts b/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToSubItems.ts new file mode 100644 index 000000000..1f6d22570 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToSubItems.ts @@ -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, +// }); +// } diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/createAndPayInvoice.ts b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/createAndPayInvoice.ts new file mode 100644 index 000000000..23619270e --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/createAndPayInvoice.ts @@ -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 => { + // 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, + }); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/lineItemsToStripeParams.ts b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/lineItemsToStripeParams.ts new file mode 100644 index 000000000..ad93545f6 --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/lineItemsToStripeParams.ts @@ -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 })); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/payStripeInvoice.ts b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/payStripeInvoice.ts new file mode 100644 index 000000000..f3f7f01ea --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/payStripeInvoice.ts @@ -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 => { + // 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 => { + 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, + }; + } +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/stripeInvoiceOps.ts b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/stripeInvoiceOps.ts new file mode 100644 index 000000000..b87b7befa --- /dev/null +++ b/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoicing/stripeInvoiceOps.ts @@ -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 => { + 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 => { + 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 => { + const invoice = await stripeCli.invoices.finalizeInvoice(invoiceId, { + auto_advance: autoAdvance, + }); + + return invoice; +}; diff --git a/server/src/internal/billing/v2/common/createAttachContext/createAttachContext.ts b/server/src/internal/billing/v2/common/createAttachContext/createAttachContext.ts index c06599f7f..d3a7a2f06 100644 --- a/server/src/internal/billing/v2/common/createAttachContext/createAttachContext.ts +++ b/server/src/internal/billing/v2/common/createAttachContext/createAttachContext.ts @@ -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, - }); }; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts index e73ccf28f..ccd66d7ca 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts @@ -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 }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 827ee0ff2..0d90b5f17 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -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; } diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts index b1d1fd5c0..9d5473c52 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.ts @@ -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; diff --git a/server/src/trigger/adjustAllowance.ts b/server/src/trigger/adjustAllowance.ts index aabaaed54..554849bab 100644 --- a/server/src/trigger/adjustAllowance.ts +++ b/server/src/trigger/adjustAllowance.ts @@ -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 }; diff --git a/server/src/utils/importUtils/addUsagePricesToSub.ts b/server/src/utils/importUtils/addUsagePricesToSub.ts index 44dac2c53..164ebc746 100644 --- a/server/src/utils/importUtils/addUsagePricesToSub.ts +++ b/server/src/utils/importUtils/addUsagePricesToSub.ts @@ -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`); - } -}; diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 95461eea9..f242f0d24 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -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; }); diff --git a/server/tests/utils/mockUtils/entityMocks.ts b/server/tests/utils/mockUtils/entityMocks.ts index 546841314..9160c74bf 100644 --- a/server/tests/utils/mockUtils/entityMocks.ts +++ b/server/tests/utils/mockUtils/entityMocks.ts @@ -23,3 +23,4 @@ export const createMockEntity = ({ internal_feature_id: internalFeatureId ?? `internal_${featureId}`, }); + diff --git a/server/tests/utils/productUtils.ts b/server/tests/utils/productUtils.ts index 79b370078..7c5379cc1 100644 --- a/server/tests/utils/productUtils.ts +++ b/server/tests/utils/productUtils.ts @@ -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 { diff --git a/shared/index.ts b/shared/index.ts index 6261cc4f4..5a497120c 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -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"; diff --git a/shared/models/attachModels/attachContext.ts b/shared/models/attachModels/attachContext.ts index 094b68b77..9ed982d39 100644 --- a/shared/models/attachModels/attachContext.ts +++ b/shared/models/attachModels/attachContext.ts @@ -16,6 +16,7 @@ export type AttachContext = { sub?: Stripe.Subscription; schedule?: Stripe.SubscriptionSchedule; testClockFrozenTime?: number; // in milliseconds since epoch + paymentMethod?: Stripe.PaymentMethod; }; // stripeCli: Stripe; diff --git a/shared/models/billingModels/cusProductActions.ts b/shared/models/billingModels/cusProductActions.ts index af628b816..201a388b0 100644 --- a/shared/models/billingModels/cusProductActions.ts +++ b/shared/models/billingModels/cusProductActions.ts @@ -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; diff --git a/shared/models/billingModels/invoicingModels/lineItem.ts b/shared/models/billingModels/invoicingModels/lineItem.ts index 7220465a8..764bd6f3f 100644 --- a/shared/models/billingModels/invoicingModels/lineItem.ts +++ b/shared/models/billingModels/invoicingModels/lineItem.ts @@ -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; +export type LineItem = z.infer; +export type LineItemDiscount = z.infer; + +// 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; +// }; diff --git a/shared/models/billingModels/invoicingModels/lineItemContext.ts b/shared/models/billingModels/invoicingModels/lineItemContext.ts index 6418453f3..3d743d131 100644 --- a/shared/models/billingModels/invoicingModels/lineItemContext.ts +++ b/shared/models/billingModels/invoicingModels/lineItemContext.ts @@ -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; +export type LineItemContext = z.infer; diff --git a/shared/models/attachModels/cusProductActions.ts b/shared/models/billingModels/ongoingCusProductAction.ts similarity index 59% rename from shared/models/attachModels/cusProductActions.ts rename to shared/models/billingModels/ongoingCusProductAction.ts index e76459ffd..7c2f3cbea 100644 --- a/shared/models/attachModels/cusProductActions.ts +++ b/shared/models/billingModels/ongoingCusProductAction.ts @@ -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 ->; diff --git a/shared/models/billingModels/scheduledCusProductAction.ts b/shared/models/billingModels/scheduledCusProductAction.ts new file mode 100644 index 000000000..b62a83b14 --- /dev/null +++ b/shared/models/billingModels/scheduledCusProductAction.ts @@ -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 +>; diff --git a/shared/models/billingModels/stripeAdapterModels/stripeDiscountWithCoupon.ts b/shared/models/billingModels/stripeAdapterModels/stripeDiscountWithCoupon.ts new file mode 100644 index 000000000..427ee2ec5 --- /dev/null +++ b/shared/models/billingModels/stripeAdapterModels/stripeDiscountWithCoupon.ts @@ -0,0 +1,5 @@ +import type Stripe from "stripe"; + +export type StripeDiscountWithCoupon = Stripe.Discount & { + source: { coupon: Stripe.Coupon }; +}; diff --git a/shared/models/billingModels/stripeAdapterModels/stripeItemSpec.ts b/shared/models/billingModels/stripeAdapterModels/stripeItemSpec.ts new file mode 100644 index 000000000..ef01e4a62 --- /dev/null +++ b/shared/models/billingModels/stripeAdapterModels/stripeItemSpec.ts @@ -0,0 +1,7 @@ +import type { Price } from "../../productModels/priceModels/priceModels"; + +export type StripeItemSpec = { + stripePriceId: string; // stripe price ID + quantity?: number; + autumnPrice?: Price; +}; diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index b9fd51a56..2a188057e 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -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"; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts index 1fbb37d64..0b8811ffa 100644 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts @@ -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; }; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts index 8c049a965..6ecc81470 100644 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts @@ -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; }; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts index 9191fd15c..1ca1240cf 100644 --- a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/fixedPriceToLineDescription.ts @@ -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; }; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts index 5c8b40f86..bd922ea35 100644 --- a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts @@ -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)}`; }; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription.ts index 61a259bfd..02ab28497 100644 --- a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription.ts +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription.ts @@ -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)})`; // } diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts new file mode 100644 index 000000000..c8184845e --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts @@ -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); +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumablePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumablePriceToLineItem.ts deleted file mode 100644 index 773d2a617..000000000 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/consumablePriceToLineItem.ts +++ /dev/null @@ -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, - }; -}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts index 3e360693d..e02fe8f32 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts @@ -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, + }); }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/prepaidPriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/prepaidPriceToLineItem.ts deleted file mode 100644 index 83104f02d..000000000 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/prepaidPriceToLineItem.ts +++ /dev/null @@ -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, - }; -}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts new file mode 100644 index 000000000..e7fbed5ec --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -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, + }); +}; diff --git a/shared/utils/billingUtils/invoicingUtils/prorationUtils/applyProration.ts b/shared/utils/billingUtils/invoicingUtils/prorationUtils/applyProration.ts index 717bb9a03..95aeb83d4 100644 --- a/shared/utils/billingUtils/invoicingUtils/prorationUtils/applyProration.ts +++ b/shared/utils/billingUtils/invoicingUtils/prorationUtils/applyProration.ts @@ -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(); }; diff --git a/shared/utils/common/unixUtils.ts b/shared/utils/common/unixUtils.ts index 8b235804b..d4ef17c27 100644 --- a/shared/utils/common/unixUtils.ts +++ b/shared/utils/common/unixUtils.ts @@ -41,3 +41,7 @@ export const secondsToMs = ( return seconds * 1000; }; + +export const msToSeconds = (ms: number): number => { + return Math.floor(ms / 1000); +}; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index 0f5f4c217..a8f64ca3e 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -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); +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index c475919c3..4d9cb757b 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -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, }: { diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts new file mode 100644 index 000000000..03150ab40 --- /dev/null +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts @@ -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, + }; +}; diff --git a/shared/utils/cusProductUtils/filterCusProductUtils.ts b/shared/utils/cusProductUtils/filterCusProductUtils.ts index 4920900bf..5aa279ea8 100644 --- a/shared/utils/cusProductUtils/filterCusProductUtils.ts +++ b/shared/utils/cusProductUtils/filterCusProductUtils.ts @@ -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), + ); +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index d7ad85cb9..a67acbf77 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -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"; diff --git a/shared/utils/orgUtils/convertOrgUtils.ts b/shared/utils/orgUtils/convertOrgUtils.ts index b68d39e9e..501cdbdcc 100644 --- a/shared/utils/orgUtils/convertOrgUtils.ts +++ b/shared/utils/orgUtils/convertOrgUtils.ts @@ -7,3 +7,7 @@ export const orgToInStatuses = ({ org }: { org: Organization }) => { } return [CusProductStatus.Active]; }; + +export const orgToCurrency = ({ org }: { org: Organization }) => { + return org.default_currency || "usd"; +};