diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 621676e8d..92f23f6b3 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -34,6 +34,7 @@ import { priceToIntervalKey, } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { ItemSet } from "@/utils/models/ItemSet.js"; const getIntervalToPrices = (prices: Price[]) => { const intervalToPrices: Record = {}; @@ -296,9 +297,21 @@ export const getStripeSubItems2 = async ({ if (price.config.interval === BillingInterval.OneOff) { invoiceItems.push(lineItem); } else { - subItems.push(lineItem); + subItems.push({ + ...lineItem, + autumnPrice: price, + }); } } - return { subItems, invoiceItems, usageFeatures }; + return { subItems, invoiceItems, usageFeatures } as ItemSet; +}; + +export const sanitizeSubItems = (subItems: any[]) => { + return subItems.map((si) => { + const { autumnPrice, ...rest } = si; + return { + ...rest, + }; + }); }; diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index 7c5e889e1..94fc66c99 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -47,7 +47,8 @@ export const findStripeItemForPrice = ({ } else { return ( config.stripe_price_id == si.price?.id || - config.stripe_product_id == si.price?.product + config.stripe_product_id == si.price?.product || + config.stripe_empty_price_id == si.price?.id ); } }); diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts index da94e31c4..dddd4eca4 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts @@ -9,6 +9,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { getAlignedIntervalUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; +import { ItemSet } from "@/utils/models/ItemSet.js"; // Get payment method @@ -35,11 +37,7 @@ export const createStripeSub2 = async ({ attachParams: AttachParams; config: AttachConfig; anchorToUnix?: number; - itemSet: { - subItems: Stripe.SubscriptionItem[]; - invoiceItems: any[]; - usageFeatures: string[]; - }; + itemSet: ItemSet; earliestInterval?: IntervalConfig | null; }) => { const { customer, invoiceOnly, freeTrial, org, now, reward } = attachParams; @@ -94,7 +92,7 @@ export const createStripeSub2 = async ({ const subscription = await stripeCli.subscriptions.create({ ...paymentMethodData, customer: customer.processor.id, - items: subItems as any, + items: sanitizeSubItems(subItems), // items: subItems as any, billing_mode: { type: "flexible" }, trial_end: freeTrialToStripeTimestamp({ freeTrial, now }), diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 7060edac3..994c20aa1 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -30,6 +30,9 @@ import { addBillingIntervalUnix } from "@/internal/products/prices/billingInterv import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; +import { getCustomerSub } from "../../attachUtils/convertAttachParams.js"; +import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; +import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; export const handlePaidProduct = async ({ req, @@ -74,68 +77,95 @@ export const handlePaidProduct = async ({ ); } - let mergeSub = await cusProductToSub({ - cusProduct: mergeCusProduct!, - stripeCli, - }); + // let mergeSub = await cusProductToSub({ + // cusProduct: mergeCusProduct!, + // stripeCli, + // }); - let billingCycleAnchorUnix = undefined; - if (attachParams.billingAnchor) { - billingCycleAnchorUnix = attachParams.billingAnchor; - } - - const earliestInterval = getSmallestInterval({ prices: attachParams.prices }); + const mergeSub = await getCustomerSub({ attachParams }); + let sub: Stripe.Subscription | null; + // 1. If merge sub if (mergeSub) { - const { end } = subToPeriodStartEnd({ sub: mergeSub }); - billingCycleAnchorUnix = end * 1000; - } + // Update + sub = mergeSub; - let newSub; - try { - newSub = await createStripeSub2({ - db: req.db, - stripeCli, + const newItemSet = await paramsToSubItems({ + req, + sub, attachParams, - itemSet, - anchorToUnix: billingCycleAnchorUnix, - earliestInterval, config, }); - } catch (error: any) { - if ( - error instanceof RecaseError && - !invoiceOnly && - error.code == ErrCode.CreateStripeSubscriptionFailed - ) { - return await handleCreateCheckout({ - req, - res, - attachParams, - config, - }); + + const { updatedSub } = await updateStripeSub2({ + req, + attachParams, + curSub: sub, + itemSet: newItemSet, + config, + fromCreate: true, + }); + + sub = updatedSub; + + // 1. + } else { + // 2. If merge sub interval + let billingCycleAnchorUnix = undefined; + if (attachParams.billingAnchor) { + billingCycleAnchorUnix = attachParams.billingAnchor; } - throw error; - } + const earliestInterval = getSmallestInterval({ + prices: attachParams.prices, + }); - subscriptions.push(newSub); + if (mergeSub) { + const { end } = subToPeriodStartEnd({ sub: mergeSub }); + billingCycleAnchorUnix = end * 1000; + } - const anchorToUnix = getEarliestPeriodEnd({ sub: newSub }) * 1000; - - const batchInsertInvoice: any = []; - for (const sub of subscriptions) { - if (!sub.latest_invoice) continue; - batchInsertInvoice.push( - insertInvoiceFromAttach({ + try { + sub = await createStripeSub2({ db: req.db, - stripeInvoice: sub.latest_invoice as Stripe.Invoice, + stripeCli, attachParams, - logger, - }) - ); + itemSet, + anchorToUnix: billingCycleAnchorUnix, + earliestInterval, + config, + }); + } catch (error: any) { + if ( + error instanceof RecaseError && + !invoiceOnly && + error.code == ErrCode.CreateStripeSubscriptionFailed + ) { + return await handleCreateCheckout({ + req, + res, + attachParams, + config, + }); + } + + throw error; + } } - const invoices = await Promise.all(batchInsertInvoice); + + subscriptions.push(sub); + + let invoice: Stripe.Invoice | undefined; + if (sub?.latest_invoice) { + invoice = await insertInvoiceFromAttach({ + db: req.db, + stripeInvoice: sub.latest_invoice as Stripe.Invoice, + attachParams, + logger, + }); + } + + const anchorToUnix = getEarliestPeriodEnd({ sub }) * 1000; if (config.invoiceCheckout) { return { @@ -176,7 +206,7 @@ export const handlePaidProduct = async ({ product_ids: products.map((p) => p.id), customer_id: customer.id || customer.internal_id, invoice: invoiceOnly - ? attachToInvoiceResponse({ invoice: invoices?.[0] }) + ? attachToInvoiceResponse({ invoice }) : undefined, }) ); @@ -186,7 +216,7 @@ export const handlePaidProduct = async ({ message: `Successfully created subscriptions and attached ${products .map((p) => p.name) .join(", ")} to ${customer.name}`, - invoice: invoiceOnly ? invoices?.[0] : undefined, + invoice: invoiceOnly ? invoice : undefined, }); } } @@ -264,3 +294,17 @@ export const handlePaidProduct = async ({ // throw error; // } // } + +// const batchInsertInvoice: any = []; +// for (const sub of subscriptions) { +// if (!sub.latest_invoice) continue; +// batchInsertInvoice.push( +// insertInvoiceFromAttach({ +// db: req.db, +// stripeInvoice: sub.latest_invoice as Stripe.Invoice, +// attachParams, +// logger, +// }) +// ); +// } +// const invoices = await Promise.all(batchInsertInvoice); diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index 061b7632c..25acd9366 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -23,6 +23,7 @@ import { getEarliestPeriodEnd, subToPeriodStartEnd, } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; export const handleUpgradeFlow = async ({ req, @@ -52,14 +53,23 @@ export const handleUpgradeFlow = async ({ config, }); - const newSubItems = await removeCurCusProductItems({ - sub: curSub, - cusProduct: curCusProduct!, - subItems: itemSet.subItems, + // const newSubItems = await removeCurCusProductItems({ + // sub: curSub, + // cusProduct: curCusProduct!, + // subItems: itemSet.subItems, + // }); + + const newItemSet = await paramsToSubItems({ + req, + sub: curSub!, + attachParams, + config, }); - if (newSubItems.length > 0) { - itemSet.subItems = newSubItems; + const { subItems } = newItemSet; + + if (subItems.length > 0) { + itemSet.subItems = subItems; logger.info(`1. Updating subs with new items`); const res = await updateStripeSub2({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 1c0f6522f..cef359813 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -11,6 +11,8 @@ import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateS import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getContUseInvoiceItems } from "../../attachUtils/getContUseItems/getContUseInvoiceItems.js"; +import { ItemSet } from "@/utils/models/ItemSet.js"; +import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; export const updateStripeSub2 = async ({ req, @@ -18,15 +20,14 @@ export const updateStripeSub2 = async ({ config, curSub, itemSet, + fromCreate = false, }: { req: ExtendedRequest; attachParams: AttachParams; config: AttachConfig; curSub: Stripe.Subscription; - itemSet: { - subItems: Stripe.SubscriptionItem[]; - invoiceItems: Stripe.InvoiceItem[]; - }; + itemSet: ItemSet; + fromCreate?: boolean; }) => { const { db, logger } = req; const { curMainProduct } = attachParamToCusProducts({ attachParams }); @@ -52,9 +53,13 @@ export const updateStripeSub2 = async ({ // 1. Update subscription let updatedSub = await stripeCli.subscriptions.update(curSub.id, { - items: itemSet.subItems, + items: sanitizeSubItems(itemSet.subItems), proration_behavior: - proration == ProrationBehavior.None ? "none" : "create_prorations", + proration == ProrationBehavior.None + ? "none" + : fromCreate + ? "always_invoice" + : "create_prorations", trial_end: trialEnd, default_payment_method: paymentMethod?.id, // add_invoice_items: itemSet.invoiceItems, @@ -75,6 +80,13 @@ export const updateStripeSub2 = async ({ }; } + if (fromCreate) { + return { + updatedSub, + latestInvoice: curSub.latest_invoice as Stripe.Invoice, + }; + } + // 2. Create prorations for single use items let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({ db, diff --git a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts index f514e346e..ec5b3142d 100644 --- a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts @@ -46,6 +46,64 @@ export const attachParamsToProduct = ({ }; }; +export const getCustomerSub = async ({ + attachParams, +}: { + attachParams: AttachParams; +}) => { + const { stripeCli } = attachParams; + const fullCus = attachParams.customer; + let cusProducts = fullCus.customer_products; + + const targetGroup = attachParams.products[0].group; + const targetEntityId = attachParams.internalEntityId || null; + const targetProductId = attachParams.products[0].id; + + cusProducts.sort((a, b) => { + // 1. Check same group + const aGroupMatches = a.product.group === targetGroup; + const bGroupMatches = b.product.group === targetGroup; + + if (aGroupMatches && !bGroupMatches) return -1; + if (!aGroupMatches && bGroupMatches) return 1; + + // 2. Check main product + const aMain = !a.product.is_add_on; + const bMain = !b.product.is_add_on; + + if (aMain && !bMain) return -1; + if (!aMain && bMain) return 1; + + // 3. Check same product + const aProductIdMatches = a.product.id === targetProductId; + const bProductIdMatches = b.product.id === targetProductId; + + if (aProductIdMatches && !bProductIdMatches) return -1; + if (!aProductIdMatches && bProductIdMatches) return 1; + + // 4. Check same entity + const aEntityIdMatches = (a.internal_entity_id || null) === targetEntityId; + const bEntityIdMatches = (b.internal_entity_id || null) === targetEntityId; + + if (aEntityIdMatches && !bEntityIdMatches) return -1; + if (!aEntityIdMatches && bEntityIdMatches) return 1; + + return 0; + }); + + const subId = cusProducts.flatMap((cp) => cp.subscription_ids || [])?.[0]; + + if (!subId) { + return undefined; + } + + const sub = await stripeCli.subscriptions.retrieve(subId, { + expand: ["items.data.price.tiers"], + }); + + return sub; +}; + export const paramsToCurSub = async ({ attachParams, }: { diff --git a/server/src/internal/customers/attach/mergeUtils/mergeNewSubItems.ts b/server/src/internal/customers/attach/mergeUtils/mergeNewSubItems.ts new file mode 100644 index 000000000..e0c42a1d6 --- /dev/null +++ b/server/src/internal/customers/attach/mergeUtils/mergeNewSubItems.ts @@ -0,0 +1,43 @@ +import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { ItemSet } from "@/utils/models/ItemSet.js"; +import Stripe from "stripe"; + +export const mergeNewSubItems = ({ + itemSet, + curSubItems, +}: { + itemSet: ItemSet; + curSubItems: Stripe.SubscriptionItem[]; +}) => { + // 1. Don't need to add arrear prices if they already exist... + let newSubItems = structuredClone(itemSet.subItems); + const newArrearSubItems: any[] = []; + + newSubItems = newSubItems.filter((newSi) => { + const existingItem = curSubItems.find((si) => si.price?.id === newSi.price); + if (isArrearPrice({ price: newSi.autumnPrice }) && existingItem) { + newArrearSubItems.push(newSi); + return false; + } + return true; + }); + + // 2. Add new subItems + for (let i = 0; i < newSubItems.length; i++) { + const newItem = newSubItems[i]; + const existingItem = curSubItems.find( + (si) => si.price?.id === newItem.price + ); + + if (!existingItem) continue; + + newSubItems[i] = { + id: existingItem.id, + quantity: (existingItem.quantity || 0) + (newItem.quantity || 0), + // price: newItem.price, + // autumnPrice: newItem.autumnPrice, + }; + } + + return newSubItems; +}; diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts new file mode 100644 index 000000000..6bcef40d4 --- /dev/null +++ b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts @@ -0,0 +1,177 @@ +import Stripe from "stripe"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; +import { AttachConfig, FullCusProduct } from "@autumn/shared"; +import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js"; +import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js"; + +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { cusProductToPrices } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { + isArrearPrice, + isUsagePrice, +} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { + findStripeItemForPrice, + subItemInCusProduct, +} from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { mergeNewSubItems } from "./mergeNewSubItems.js"; +import { formatPrice } from "@/internal/products/prices/priceUtils.js"; + +const getCusProductsToRemove = ({ + attachParams, +}: { + attachParams: AttachParams; +}) => { + const products = attachParams.products; + const cusProducts = attachParams.cusProducts; + + const cusProductsToRemove: FullCusProduct[] = []; + for (const product of products) { + // Get cur main and cur same + const { curMainProduct, curSameProduct } = getExistingCusProducts({ + product, + cusProducts: attachParams.cusProducts, + internalEntityId: attachParams.internalEntityId, + }); + + // 1. If product is an add on, and there's current same, add it + if (product.is_add_on && curSameProduct) { + cusProductsToRemove.push(curSameProduct); + } + + // 2. If product is a main product, add curMain + if (curMainProduct) { + cusProductsToRemove.push(curMainProduct); + } + } + + // Get unique cus products, by cusProduct.id + const uniqueCusProductsToRemove = cusProductsToRemove.filter( + (cusProduct, index, array) => + array.findIndex((cp) => cp.id === cusProduct.id) === index + ); + + return uniqueCusProductsToRemove; +}; + +export const paramsToSubItems = async ({ + req, + sub, + attachParams, + config, +}: { + req: ExtendedRequest; + sub: Stripe.Subscription; + attachParams: AttachParams; + config: AttachConfig; +}) => { + const { logger } = req; + const curSubItems = sub.items.data; + const itemSet = await getStripeSubItems2({ + attachParams, + config, + }); + + // 1. Remove items related to cur cus product... + const cusProductsToRemove = getCusProductsToRemove({ attachParams }); + + const newSubItems = mergeNewSubItems({ + itemSet, + curSubItems, + }); + + const allCusProducts = attachParams.customer.customer_products; + + console.log( + `REMOVING CUS PRODUCTS:`, + cusProductsToRemove.map((cp) => `${cp.product.id}`) + ); + + // 3. Remove items related to cus products to remove + for (const cusProduct of cusProductsToRemove) { + const prices = cusProductToPrices({ cusProduct }); + // console.log( + // `Prices:`, + // prices.map((p) => formatPrice({ price: p })) + // ); + for (const price of prices) { + const existingSubItem = findStripeItemForPrice({ + price, + stripeItems: curSubItems, + stripeProdId: cusProduct.product.processor?.id, + }); + + if (!existingSubItem) continue; + + // 1. If arrear price + if (isArrearPrice({ price })) { + if ( + allCusProducts.some((cp) => { + if (cp.id === cusProduct.id) return false; + + return subItemInCusProduct({ + cusProduct: cp, + subItem: existingSubItem as Stripe.SubscriptionItem, + }); + }) + ) { + continue; + } + + if ( + itemSet.subItems.some((si) => si.price == existingSubItem.price?.id) + ) { + continue; + } + + newSubItems.push({ + id: existingSubItem.id, + deleted: true, + }); + + continue; + } + + // Helper function to handle quantity updates and deletion + const updateItemQuantity = (item: any, newQuantity: number) => { + if (newQuantity <= 0) { + item.deleted = true; + item.quantity = undefined; + } else { + item.quantity = newQuantity; + } + }; + + // 1. Get quantity to remove + const quantityToRemove = 1; + + // 2. Check if item already exists in newSubItems + const existingItemIndex = newSubItems.findIndex( + (si) => si.id === existingSubItem.id + ); + + if (existingItemIndex !== -1) { + // Update existing item in newSubItems + const currentQuantity = newSubItems[existingItemIndex].quantity || 0; + const newQuantity = currentQuantity - quantityToRemove; + updateItemQuantity(newSubItems[existingItemIndex], newQuantity); + } else { + // Add new item to newSubItems + const currentQuantity = existingSubItem.quantity || 0; + const newQuantity = currentQuantity - quantityToRemove; + newSubItems.push({ + id: existingSubItem.id, + quantity: newQuantity, + }); + updateItemQuantity(newSubItems[newSubItems.length - 1], newQuantity); + } + } + } + + return { + subItems: newSubItems, + invoiceItems: itemSet.invoiceItems, + usageFeatures: itemSet.usageFeatures, + }; +}; diff --git a/server/src/internal/customers/attach/unifiedAttach/createUpgradeProrationItems.ts b/server/src/internal/customers/attach/unifiedAttach/createUpgradeProrationItems.ts deleted file mode 100644 index 0b24f1c37..000000000 --- a/server/src/internal/customers/attach/unifiedAttach/createUpgradeProrationItems.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { AttachConfig, intervalsSame } from "@autumn/shared"; -import { subToAutumnInterval } from "@/external/stripe/utils.js"; -import Stripe from "stripe"; -import { ItemSet } from "@/utils/models/ItemSet.js"; -import { attachParamsToCurCusProduct } from "../attachUtils/convertAttachParams.js"; - -// 1. Create proration items for fixed prices -const createFixedPriceProrations = async ({ - req, - attachParams, - config, - currentSubs, - itemSets, -}: { - req: ExtendedRequest; - attachParams: AttachParams; - currentSubs: Stripe.Subscription[]; - config: AttachConfig; - itemSets: ItemSet[]; -}) => { - const { stripeCli } = attachParams; - const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - - for (const sub of currentSubs) { - const itemSet = itemSets.find((itemSet) => - intervalsSame({ - intervalA: itemSet, - intervalB: subToAutumnInterval(sub), - }) - ); - - // const { newItems, shouldCancel } = mergeWithCurItems({ - // sub, - // itemSet, - // curCusProduct, - // }); - // subToNewItems.push({ - // sub, - // newItems, - // shouldCancel, - // }); - - // if (newItems.length == 0) continue; - // if (shouldCancel) { - // const preview = await stripeCli.invoices.createPreview({ - // subscription: sub.id, - // subscription_details: { - // cancel_now: true, - // }, - // }); - // for (const lineItem of preview.lines.data) { - // await stripeCli.invoiceItems.create({ - // customer: attachParams.customer.processor.id, - // amount: lineItem.amount, - // currency: lineItem.currency, - // description: lineItem.description || "", - // }); - // } - // } else { - // const [originalPreview, previewInvoice] = await Promise.all([ - // stripeCli.invoices.createPreview({ - // subscription: sub.id, - // }), - // stripeCli.invoices.createPreview({ - // subscription: sub.id, - // subscription_details: { - // items: newItems, - // }, - // }), - // ]); - // for (const lineItem of previewInvoice.lines.data) { - // const inCurItems = originalPreview.lines.data.find( - // (i) => i.id == lineItem.id - // ); - // if (!inCurItems) { - // // console.log(lineItem.description, lineItem.amount); - // // prorationItems.push(); - // await stripeCli.invoiceItems.create({ - // customer: attachParams.customer.processor.id, - // amount: lineItem.amount, - // currency: lineItem.currency, - // description: lineItem.description || "", - // }); - // } - // } - // } - } -}; diff --git a/server/src/internal/customers/attach/unifiedAttach/handleUnifiedAttach.ts b/server/src/internal/customers/attach/unifiedAttach/handleUnifiedAttach.ts deleted file mode 100644 index 6c5742eea..000000000 --- a/server/src/internal/customers/attach/unifiedAttach/handleUnifiedAttach.ts +++ /dev/null @@ -1,302 +0,0 @@ -// import Stripe from "stripe"; -// import { DrizzleCli } from "@/db/initDrizzle.js"; -// import { createStripeSub } from "@/external/stripe/stripeSubUtils/createStripeSub.js"; -// import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -// import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -// import { -// AttachConfig, -// FullCusProduct, -// intervalsSame, -// ProrationBehavior, -// } from "@autumn/shared"; -// import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -// import { ExtendedRequest } from "@/utils/models/Request.js"; - -// export const handleUnifiedAttach = async ({ -// req, -// attachParams, -// config, -// }: { -// req: ExtendedRequest; -// attachParams: AttachParams; -// config: AttachConfig; -// }) => { -// // 1. Get upcoming item sets, and current subscriptions -// const itemSets = await getStripeSubItems({ -// attachParams, -// carryExistingUsages: config.carryUsage, -// }); - -// const currentSubs = await getCurrentSubs({ -// db: req.db, -// attachParams, -// }); - -// // 2. Create invoice items for prorations -// }; - -// Expire old cus product -// Create new full cus product -// Insert new invoices - -// export const updateSubsDiffInt = async ({ -// req, -// stripeCli, -// curCusProduct, -// attachParams, -// stripeSubs, -// config, -// }: { -// req: ExtendedRequest; -// stripeCli: Stripe; -// curCusProduct: FullCusProduct; -// attachParams: AttachParams; -// stripeSubs: Stripe.Subscription[]; -// config: AttachConfig; -// }) => { -// const { db, logger } = req; -// const itemSets = await getStripeSubItems({ -// attachParams, -// carryExistingUsages: config.carryUsage, -// }); - -// // let trialEnd = config.disableTrial -// // ? undefined -// // : freeTrialToStripeTimestamp({ -// // freeTrial: attachParams.freeTrial, -// // now: attachParams.now, -// // }); - -// // 1. Create prorations for fixed price items -// const subToNewItems: any = []; - -// for (const sub of stripeSubs) { -// const itemSet = itemSets.find((itemSet) => -// intervalsSame({ -// intervalA: itemSet, -// intervalB: subToAutumnInterval(sub), -// }) -// ); - -// const { newItems, shouldCancel } = mergeWithCurItems({ -// sub, -// itemSet, -// curCusProduct, -// }); - -// subToNewItems.push({ -// sub, -// newItems, -// shouldCancel, -// }); - -// if (newItems.length == 0) continue; - -// if (shouldCancel) { -// const preview = await stripeCli.invoices.createPreview({ -// subscription: sub.id, -// subscription_details: { -// cancel_now: true, -// }, -// }); - -// for (const lineItem of preview.lines.data) { -// await stripeCli.invoiceItems.create({ -// customer: attachParams.customer.processor.id, -// amount: lineItem.amount, -// currency: lineItem.currency, -// description: lineItem.description || "", -// }); -// } -// } else { -// const [originalPreview, previewInvoice] = await Promise.all([ -// stripeCli.invoices.createPreview({ -// subscription: sub.id, -// }), -// stripeCli.invoices.createPreview({ -// subscription: sub.id, -// subscription_details: { -// items: newItems, -// }, -// }), -// ]); - -// for (const lineItem of previewInvoice.lines.data) { -// const inCurItems = originalPreview.lines.data.find( -// (i) => i.id == lineItem.id -// ); - -// if (!inCurItems) { -// // console.log(lineItem.description, lineItem.amount); -// // prorationItems.push(); -// await stripeCli.invoiceItems.create({ -// customer: attachParams.customer.processor.id, -// amount: lineItem.amount, -// currency: lineItem.currency, -// description: lineItem.description || "", -// }); -// } -// } -// } -// } - -// for (const subToNewItem of subToNewItems) { -// console.log(`Sub to new item`); -// console.log(`Sub ID: ${subToNewItem.sub.id}`); -// console.log(`New items: ${JSON.stringify(subToNewItem.newItems)}`); -// console.log(`Should cancel: ${subToNewItem.shouldCancel}`); -// console.log(`--------------------------------`); -// } -// throw new Error("Stop"); - -// // 2. Create prorations for single use items -// let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({ -// db, -// attachParams, -// cusProduct: curCusProduct, -// stripeSubs, -// logger, -// }); - -// // Create any new subs -// const createItemSets = itemSets.filter((itemSet) => { -// return !stripeSubs.some((sub) => -// intervalsSame({ -// intervalA: itemSet, -// intervalB: subToAutumnInterval(sub), -// }) -// ); -// }); - -// const updatedSubs = []; -// const invoices = []; - -// // 1. Create new subscriptions -// for (const itemSet of createItemSets) { -// const newSub = await createStripeSub({ -// db, -// attachParams, -// itemSet, -// }); - -// updatedSubs.push(newSub); -// invoices.push(newSub.latest_invoice as Stripe.Invoice); -// } - -// // 2. Update or cancel old subs -// for (const newItemSet of subToNewItems) { -// if (newItemSet.shouldCancel) { -// await stripeCli.subscriptions.cancel(newItemSet.sub.id, { -// prorate: false, -// cancellation_details: { -// comment: "autumn_upgrade", -// }, -// }); -// } else { -// const intervalConfig = subToAutumnInterval(newItemSet.sub); -// const { updatedSub, latestInvoice } = await updateStripeSub({ -// req, -// attachParams, -// config: { -// ...config, -// proration: ProrationBehavior.None, -// }, -// stripeSubs: [newItemSet.sub], -// itemSet: { -// items: newItemSet.newItems, -// interval: intervalConfig.interval, -// intervalCount: intervalConfig.intervalCount, -// } as any, -// intervalConfig: subToAutumnInterval(newItemSet.sub), -// }); - -// updatedSubs.push(updatedSub!); -// if (latestInvoice) { -// invoices.push(latestInvoice); -// } -// } -// } - -// return { -// newSubs: stripeSubs, -// invoices, -// // invoice: latestInvoice, -// // newInvoiceIds, -// }; -// }; - -// 2. Update / cancel old subscriptions - -// // 3. Update current subscription -// logger.info("1.2: Updating current subscription"); -// const { updatedSub, latestInvoice } = await updateStripeSub({ -// req, -// attachParams, -// config, -// trialEnd, -// itemSet: firstItemSet, -// stripeSubs: [firstSub], -// }); - -// await resetUsageBalances({ -// db, -// cusEntIds, -// cusProduct: curCusProduct, -// }); - -// let newSubs = [updatedSub!]; -// const newInvoiceIds = latestInvoice ? [latestInvoice.id] : []; - -// // 4. Update current sub schedules if exist... -// logger.info("1.3 Updating current sub schedules"); -// await updateCurSchedules({ -// db, -// stripeCli, -// curCusProduct, -// attachParams, -// itemSets, -// logger, -// }); - -// // 5. Cancel other subscriptions -// for (const sub of stripeSubs.slice(1)) { -// logger.info(`1.4: canceling additional sub: ${sub.id}`); - -// // Filter out -// await stripeCli.subscriptions.cancel(sub.id, { -// prorate: true, -// cancellation_details: { -// comment: "autumn_upgrade", -// }, -// }); -// } - -// // 6. Create subs for other intervals -// for (const itemSet of itemSets.slice(1)) { -// const newSub = await createStripeSub({ -// db, -// stripeCli, -// customer: attachParams.customer, -// org: attachParams.org, -// itemSet, -// invoiceOnly: attachParams.invoiceOnly || false, -// freeTrial: attachParams.freeTrial, -// // anchorToUnix: updatedSub!.current_period_end! * 1000, -// now: attachParams.now, -// }); - -// newSubs.push(newSub); -// const latestInvoice = newSub.latest_invoice as Stripe.Invoice; -// newInvoiceIds.push(latestInvoice.id); -// } - -// const firstSub = stripeSubs?.[0]; -// const firstItemSet = itemSets?.[0]; - -// await addSubItemsToRemove({ -// sub: firstSub, -// cusProduct: curCusProduct, -// itemSet: firstItemSet, -// }); - -// throw new Error("Stop"); diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts index 8404e5962..9e0f93823 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts @@ -40,6 +40,12 @@ export const isUsagePrice = ({ return isUsage; }; +export const isArrearPrice = ({ price }: { price?: Price }) => { + if (!price) return false; + let billingType = getBillingType(price.config); + return billingType == BillingType.UsageInArrear; +}; + export const isPrepaidPrice = ({ price }: { price: Price }) => { let billingType = getBillingType(price.config); return billingType == BillingType.UsageInAdvance; diff --git a/server/src/utils/models/ItemSet.ts b/server/src/utils/models/ItemSet.ts index 99895b8ea..4fbf55886 100644 --- a/server/src/utils/models/ItemSet.ts +++ b/server/src/utils/models/ItemSet.ts @@ -1,13 +1,30 @@ -import { BillingInterval } from "@autumn/shared"; +import { BillingInterval, Price } from "@autumn/shared"; +import Stripe from "stripe"; import { z } from "zod"; -export const ItemSetSchema = z.object({ - items: z.array(z.any()), - prices: z.array(z.any()), - interval: z.nativeEnum(BillingInterval), - intervalCount: z.number(), - subMeta: z.record(z.string(), z.any()), - usageFeatures: z.array(z.string()), -}); +// export const ItemSetSchema = z.object({ +// items: z.array(z.any()), +// prices: z.array(z.any()), +// interval: z.nativeEnum(BillingInterval), +// intervalCount: z.number(), +// subMeta: z.record(z.string(), z.any()), +// usageFeatures: z.array(z.string()), +// }); -export type ItemSet = z.infer; +// export type ItemSet = z.infer; + +export type ItemSet = { + subItems: (Stripe.SubscriptionUpdateParams.Item & { + autumnPrice?: Price; + })[]; + invoiceItems: Stripe.SubscriptionUpdateParams.AddInvoiceItem[]; + usageFeatures: string[]; +}; + +// export const ItemSetSchema2 = z.object({ +// subItems: z.array(z.any()), +// invoiceItems: z.array(z.any()), +// usageFeatures: z.array(z.any()), +// }); + +// export type ItemSet2 = z.infer; diff --git a/server/tests/merged/mergeUtils.test.ts b/server/tests/merged/mergeUtils.test.ts new file mode 100644 index 000000000..291b5ec83 --- /dev/null +++ b/server/tests/merged/mergeUtils.test.ts @@ -0,0 +1,22 @@ +import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { FullCusProduct } from "@autumn/shared"; + +export const cusProductToSubIds = ({ + cusProducts, +}: { + cusProducts: FullCusProduct[]; +}) => { + return [...new Set(cusProducts.flatMap((cp) => cp.subscription_ids || []))]; +}; + +export const cpToPrice = ({ + cp, + type, +}: { + cp: FullCusProduct; + type: "base" | "arrear" | "cont" | "prepaid"; +}) => { + const prices = cusProductToPrices({ cusProduct: cp }); + return prices.find((p) => isFixedPrice({ price: p })); +}; diff --git a/server/tests/merged/mergedAdd1.ts b/server/tests/merged/mergedAdd1.ts new file mode 100644 index 000000000..e412f131f --- /dev/null +++ b/server/tests/merged/mergedAdd1.ts @@ -0,0 +1,185 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, entities, Organization } from "@autumn/shared"; +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { expect } from "chai"; +import { cpToPrice, cusProductToSubIds } from "./mergeUtils.test.js"; +import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; + +// UNCOMMENT FROM HERE +let pro = constructProduct({ + id: "pro", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "pro", +}); + +describe(`${chalk.yellowBright("mergedAdd1: Testing merged subs, with track")}`, () => { + let customerId = "mergedAdd1"; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: customerId, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro product", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: "1", + }); + + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: "2", + }); + + // 1. Should have one sub + const fullCus = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + }); + + const cusProducts = fullCus.customer_products; + expect(cusProducts.length).to.equal(3); + + const subIds = cusProductToSubIds({ cusProducts }); + expect(subIds.length).to.equal(1); + + // Get sub + const sub = await stripeCli.subscriptions.retrieve(subIds[0]); + + // 1. Should have quantity 2 for base price + const basePrice = cpToPrice({ cp: cusProducts[0], type: "base" }); + const arrearPrice = cpToPrice({ cp: cusProducts[0], type: "arrear" }); + + const baseItem = findStripeItemForPrice({ + price: basePrice!, + stripeItems: sub.items.data, + })!; + + const arrearItem = findStripeItemForPrice({ + price: arrearPrice!, + stripeItems: sub.items.data, + })!; + + expect(baseItem.quantity).to.equal(2); + expect(arrearItem.quantity).to.equal(0); + }); + + // it("should attach premium product", async function () { + // const wordsUsage = 100000; + // await autumn.track({ + // customer_id: customerId, + // feature_id: TestFeature.Words, + // value: wordsUsage, + // }); + + // curUnix = await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addWeeks(new Date(), 2).getTime(), + // waitForSeconds: 10, + // }); + + // await attachAndExpectCorrect({ + // autumn, + // customerId, + // product: premium, + // stripeCli, + // db, + // org, + // env, + // }); + // }); + + // it("should attach growth product", async function () { + // const wordsUsage = 200000; + // await autumn.track({ + // customer_id: customerId, + // feature_id: TestFeature.Words, + // value: wordsUsage, + // }); + + // curUnix = await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addWeeks(curUnix, 1).getTime(), + // waitForSeconds: 10, + // }); + + // await attachAndExpectCorrect({ + // autumn, + // customerId, + // product: growth, + // stripeCli, + // db, + // org, + // env, + // }); + // }); +});