add product

This commit is contained in:
John Yeo
2025-08-17 21:50:18 -07:00
parent 34aafbe5e3
commit 3c4f3d7262
15 changed files with 667 additions and 473 deletions

View File

@@ -34,6 +34,7 @@ import {
priceToIntervalKey, priceToIntervalKey,
} from "@/internal/products/prices/priceUtils/convertPrice.js"; } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
const getIntervalToPrices = (prices: Price[]) => { const getIntervalToPrices = (prices: Price[]) => {
const intervalToPrices: Record<string, Price[]> = {}; const intervalToPrices: Record<string, Price[]> = {};
@@ -296,9 +297,21 @@ export const getStripeSubItems2 = async ({
if (price.config.interval === BillingInterval.OneOff) { if (price.config.interval === BillingInterval.OneOff) {
invoiceItems.push(lineItem); invoiceItems.push(lineItem);
} else { } 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,
};
});
}; };

View File

@@ -47,7 +47,8 @@ export const findStripeItemForPrice = ({
} else { } else {
return ( return (
config.stripe_price_id == si.price?.id || 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
); );
} }
}); });

View File

@@ -9,6 +9,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { getAlignedIntervalUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { getAlignedIntervalUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.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 // Get payment method
@@ -35,11 +37,7 @@ export const createStripeSub2 = async ({
attachParams: AttachParams; attachParams: AttachParams;
config: AttachConfig; config: AttachConfig;
anchorToUnix?: number; anchorToUnix?: number;
itemSet: { itemSet: ItemSet;
subItems: Stripe.SubscriptionItem[];
invoiceItems: any[];
usageFeatures: string[];
};
earliestInterval?: IntervalConfig | null; earliestInterval?: IntervalConfig | null;
}) => { }) => {
const { customer, invoiceOnly, freeTrial, org, now, reward } = attachParams; const { customer, invoiceOnly, freeTrial, org, now, reward } = attachParams;
@@ -94,7 +92,7 @@ export const createStripeSub2 = async ({
const subscription = await stripeCli.subscriptions.create({ const subscription = await stripeCli.subscriptions.create({
...paymentMethodData, ...paymentMethodData,
customer: customer.processor.id, customer: customer.processor.id,
items: subItems as any, items: sanitizeSubItems(subItems),
// items: subItems as any, // items: subItems as any,
billing_mode: { type: "flexible" }, billing_mode: { type: "flexible" },
trial_end: freeTrialToStripeTimestamp({ freeTrial, now }), trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),

View File

@@ -30,6 +30,9 @@ import { addBillingIntervalUnix } from "@/internal/products/prices/billingInterv
import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.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 ({ export const handlePaidProduct = async ({
req, req,
@@ -74,68 +77,95 @@ export const handlePaidProduct = async ({
); );
} }
let mergeSub = await cusProductToSub({ // let mergeSub = await cusProductToSub({
cusProduct: mergeCusProduct!, // cusProduct: mergeCusProduct!,
stripeCli, // stripeCli,
}); // });
let billingCycleAnchorUnix = undefined; const mergeSub = await getCustomerSub({ attachParams });
if (attachParams.billingAnchor) { let sub: Stripe.Subscription | null;
billingCycleAnchorUnix = attachParams.billingAnchor;
}
const earliestInterval = getSmallestInterval({ prices: attachParams.prices });
// 1. If merge sub
if (mergeSub) { if (mergeSub) {
const { end } = subToPeriodStartEnd({ sub: mergeSub }); // Update
billingCycleAnchorUnix = end * 1000; sub = mergeSub;
}
let newSub; const newItemSet = await paramsToSubItems({
try { req,
newSub = await createStripeSub2({ sub,
db: req.db,
stripeCli,
attachParams, attachParams,
itemSet,
anchorToUnix: billingCycleAnchorUnix,
earliestInterval,
config, config,
}); });
} catch (error: any) {
if ( const { updatedSub } = await updateStripeSub2({
error instanceof RecaseError && req,
!invoiceOnly && attachParams,
error.code == ErrCode.CreateStripeSubscriptionFailed curSub: sub,
) { itemSet: newItemSet,
return await handleCreateCheckout({ config,
req, fromCreate: true,
res, });
attachParams,
config, 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; try {
sub = await createStripeSub2({
const batchInsertInvoice: any = [];
for (const sub of subscriptions) {
if (!sub.latest_invoice) continue;
batchInsertInvoice.push(
insertInvoiceFromAttach({
db: req.db, db: req.db,
stripeInvoice: sub.latest_invoice as Stripe.Invoice, stripeCli,
attachParams, 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) { if (config.invoiceCheckout) {
return { return {
@@ -176,7 +206,7 @@ export const handlePaidProduct = async ({
product_ids: products.map((p) => p.id), product_ids: products.map((p) => p.id),
customer_id: customer.id || customer.internal_id, customer_id: customer.id || customer.internal_id,
invoice: invoiceOnly invoice: invoiceOnly
? attachToInvoiceResponse({ invoice: invoices?.[0] }) ? attachToInvoiceResponse({ invoice })
: undefined, : undefined,
}) })
); );
@@ -186,7 +216,7 @@ export const handlePaidProduct = async ({
message: `Successfully created subscriptions and attached ${products message: `Successfully created subscriptions and attached ${products
.map((p) => p.name) .map((p) => p.name)
.join(", ")} to ${customer.name}`, .join(", ")} to ${customer.name}`,
invoice: invoiceOnly ? invoices?.[0] : undefined, invoice: invoiceOnly ? invoice : undefined,
}); });
} }
} }
@@ -264,3 +294,17 @@ export const handlePaidProduct = async ({
// throw error; // 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);

View File

@@ -23,6 +23,7 @@ import {
getEarliestPeriodEnd, getEarliestPeriodEnd,
subToPeriodStartEnd, subToPeriodStartEnd,
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js";
export const handleUpgradeFlow = async ({ export const handleUpgradeFlow = async ({
req, req,
@@ -52,14 +53,23 @@ export const handleUpgradeFlow = async ({
config, config,
}); });
const newSubItems = await removeCurCusProductItems({ // const newSubItems = await removeCurCusProductItems({
sub: curSub, // sub: curSub,
cusProduct: curCusProduct!, // cusProduct: curCusProduct!,
subItems: itemSet.subItems, // subItems: itemSet.subItems,
// });
const newItemSet = await paramsToSubItems({
req,
sub: curSub!,
attachParams,
config,
}); });
if (newSubItems.length > 0) { const { subItems } = newItemSet;
itemSet.subItems = newSubItems;
if (subItems.length > 0) {
itemSet.subItems = subItems;
logger.info(`1. Updating subs with new items`); logger.info(`1. Updating subs with new items`);
const res = await updateStripeSub2({ const res = await updateStripeSub2({

View File

@@ -11,6 +11,8 @@ import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateS
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js"; import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getContUseInvoiceItems } from "../../attachUtils/getContUseItems/getContUseInvoiceItems.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 ({ export const updateStripeSub2 = async ({
req, req,
@@ -18,15 +20,14 @@ export const updateStripeSub2 = async ({
config, config,
curSub, curSub,
itemSet, itemSet,
fromCreate = false,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
attachParams: AttachParams; attachParams: AttachParams;
config: AttachConfig; config: AttachConfig;
curSub: Stripe.Subscription; curSub: Stripe.Subscription;
itemSet: { itemSet: ItemSet;
subItems: Stripe.SubscriptionItem[]; fromCreate?: boolean;
invoiceItems: Stripe.InvoiceItem[];
};
}) => { }) => {
const { db, logger } = req; const { db, logger } = req;
const { curMainProduct } = attachParamToCusProducts({ attachParams }); const { curMainProduct } = attachParamToCusProducts({ attachParams });
@@ -52,9 +53,13 @@ export const updateStripeSub2 = async ({
// 1. Update subscription // 1. Update subscription
let updatedSub = await stripeCli.subscriptions.update(curSub.id, { let updatedSub = await stripeCli.subscriptions.update(curSub.id, {
items: itemSet.subItems, items: sanitizeSubItems(itemSet.subItems),
proration_behavior: proration_behavior:
proration == ProrationBehavior.None ? "none" : "create_prorations", proration == ProrationBehavior.None
? "none"
: fromCreate
? "always_invoice"
: "create_prorations",
trial_end: trialEnd, trial_end: trialEnd,
default_payment_method: paymentMethod?.id, default_payment_method: paymentMethod?.id,
// add_invoice_items: itemSet.invoiceItems, // 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 // 2. Create prorations for single use items
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({ let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db, db,

View File

@@ -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 ({ export const paramsToCurSub = async ({
attachParams, attachParams,
}: { }: {

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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 || "",
// });
// }
// }
// }
}
};

View File

@@ -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");

View File

@@ -40,6 +40,12 @@ export const isUsagePrice = ({
return isUsage; 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 }) => { export const isPrepaidPrice = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config); let billingType = getBillingType(price.config);
return billingType == BillingType.UsageInAdvance; return billingType == BillingType.UsageInAdvance;

View File

@@ -1,13 +1,30 @@
import { BillingInterval } from "@autumn/shared"; import { BillingInterval, Price } from "@autumn/shared";
import Stripe from "stripe";
import { z } from "zod"; import { z } from "zod";
export const ItemSetSchema = z.object({ // export const ItemSetSchema = z.object({
items: z.array(z.any()), // items: z.array(z.any()),
prices: z.array(z.any()), // prices: z.array(z.any()),
interval: z.nativeEnum(BillingInterval), // interval: z.nativeEnum(BillingInterval),
intervalCount: z.number(), // intervalCount: z.number(),
subMeta: z.record(z.string(), z.any()), // subMeta: z.record(z.string(), z.any()),
usageFeatures: z.array(z.string()), // usageFeatures: z.array(z.string()),
}); // });
export type ItemSet = z.infer<typeof ItemSetSchema>; // export type ItemSet = z.infer<typeof ItemSetSchema>;
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<typeof ItemSetSchema2>;

View File

@@ -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 }));
};

View File

@@ -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,
// });
// });
});