diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 9cf4a618a..487eb1111 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -18,6 +18,7 @@ import { CheckResult, Customer, TrackParams, + TransferProductParams, UsageParams, } from "autumn-js"; import { AttachBody } from "@autumn/shared"; @@ -182,6 +183,18 @@ export class AutumnInt { return data as CheckoutResult; } + async transfer( + customerId: string, + params: { + from_entity_id?: string; + to_entity_id: string; + product_id: string; + } + ) { + const data = await this.post(`/customers/${customerId}/transfer`, params); + + return data as CheckoutResult; + } async sendEvent({ customerId, diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index 46dba6779..e4d98701a 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -73,12 +73,20 @@ export const priceToScheduleItem = ({ export const findStripeItemForPrice = ({ price, stripeItems, + invoiceLineItems, stripeProdId, }: { price: Price; stripeItems?: Stripe.SubscriptionItem[] | Stripe.LineItem[]; + invoiceLineItems?: Stripe.InvoiceLineItem[]; stripeProdId?: string; }) => { + if (invoiceLineItems) { + return invoiceLineItems.find((li) => { + return li.pricing?.price_details?.price == price.config.stripe_price_id; + }); + } + if (stripeItems) { return stripeItems.find((si: Stripe.SubscriptionItem | Stripe.LineItem) => { const config = price.config as UsagePriceConfig; diff --git a/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts b/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts index a9f932133..e588f5ea3 100644 --- a/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts +++ b/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts @@ -64,6 +64,7 @@ export const createProrationInvoice = async ({ pending: true, }); + console.log("Items:", items.data); if (items.data.length == 0) { logger.info(`No items to prorate, skipping invoice creation`); return null; diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 15405a5f4..ec8f656ef 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -31,7 +31,7 @@ export const handleCreateCheckout = async ({ }) => { const { db, logtail: logger } = req; - const { customer, org, freeTrial, successUrl, reward } = attachParams; + const { customer, org, freeTrial, successUrl, rewards } = attachParams; const stripeCli = createStripeCli({ org, @@ -106,14 +106,14 @@ export const handleCreateCheckout = async ({ let checkoutParams = attachParams.checkoutSessionParams || {}; let allowPromotionCodes = - notNullish(checkoutParams.discounts) || notNullish(reward) + notNullish(checkoutParams.discounts) || notNullish(rewards) ? undefined : checkoutParams.allow_promotion_codes || true; let rewardData = {}; - if (reward) { + if (rewards) { rewardData = { - discounts: [{ coupon: reward.id }], + discounts: rewards.map((r) => ({ coupon: r.id })), }; } diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts index 883a3ee43..e36de67c9 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts @@ -43,7 +43,7 @@ export const createStripeSub2 = async ({ itemSet: ItemSet; earliestInterval?: IntervalConfig | null; }) => { - const { customer, invoiceOnly, freeTrial, org, now, reward } = attachParams; + const { customer, invoiceOnly, freeTrial, org, now, rewards } = attachParams; let paymentMethod = await getCusPaymentMethod({ stripeCli, @@ -91,6 +91,11 @@ export const createStripeSub2 = async ({ const { subItems, invoiceItems, usageFeatures } = itemSet; + const discounts = rewards + ? rewards.map((reward) => ({ coupon: reward.id })) + : undefined; + console.log("CREATING SUB, DISCOUNTS:", discounts); + try { const subscription = await stripeCli.subscriptions.create({ ...paymentMethodData, @@ -108,7 +113,7 @@ export const createStripeSub2 = async ({ : undefined, // coupon: reward ? reward.id : undefined, - discounts: reward ? [{ coupon: reward.id }] : undefined, + discounts, expand: ["latest_invoice"], trial_settings: diff --git a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts index b79623b5f..a2d93af88 100644 --- a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts @@ -24,7 +24,10 @@ import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import Stripe from "stripe"; import { createStripeSub2 } from "../addProductFlow/createStripeSub2.js"; import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; -import { attachToInvoiceResponse } from "@/internal/invoices/invoiceUtils.js"; +import { + attachToInvoiceResponse, + insertInvoiceFromAttach, +} from "@/internal/invoices/invoiceUtils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; import { ItemSet } from "@/utils/models/ItemSet.js"; @@ -103,7 +106,8 @@ export const handleMultiAttachFlow = async ({ config, curSub: curSub!, itemSet, - fromCreate: attachParams.products.length === 0, // just for now, if no products, it comes from cancel product... + // fromCreate: attachParams.products.length === 0, // just for now, if no products, it comes from cancel product... + fromCreate: true, // just for now, if no products, it comes from cancel product... }); // TODO: Add these missing functions or remove if not needed @@ -143,6 +147,15 @@ export const handleMultiAttachFlow = async ({ }); } + if (latestInvoice) { + await insertInvoiceFromAttach({ + db, + attachParams, + stripeInvoice: latestInvoice, + logger, + }); + } + // Expire all existing cus products at the customer level const batchInsert: any[] = []; for (const productOptions of productsList) { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts index d2c47e75d..22b84d750 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts @@ -153,6 +153,7 @@ export const createUsageInvoiceItems = async ({ }); const batchCreate = []; + for (let i = 0; i < invoiceItems.length; i++) { const invoiceItem = invoiceItems[i]; const createInvoiceItem = async () => { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 697c7acb5..8b2456cf5 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -14,6 +14,7 @@ import { getContUseInvoiceItems } from "../../attachUtils/getContUseItems/getCon import { ItemSet } from "@/utils/models/ItemSet.js"; import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; export const updateStripeSub2 = async ({ req, @@ -50,14 +51,15 @@ export const updateStripeSub2 = async ({ }); // 1. Update subscription + let updatedSub = await stripeCli.subscriptions.update(curSub.id, { items: sanitizeSubItems(itemSet.subItems), - proration_behavior: - proration == ProrationBehavior.None - ? "none" - : fromCreate - ? "always_invoice" - : "create_prorations", + // proration_behavior: + // proration == ProrationBehavior.None + // ? "none" + // : fromCreate + // ? "always_invoice" + // : "create_prorations", trial_end: trialEnd, // default_payment_method: paymentMethod?.id, add_invoice_items: itemSet.invoiceItems, @@ -94,30 +96,25 @@ export const updateStripeSub2 = async ({ db, attachParams, cusProduct: curMainProduct!, - // stripeSubs: [curSub], sub: curSub, logger, }); - // // 3. Create prorations for continuous use items - let { replaceables, newItems } = await getContUseInvoiceItems({ - attachParams, - cusProduct: curMainProduct!, - sub: curSub, - logger, - }); + // // // 3. Create prorations for continuous use items + // let { replaceables, newItems } = await getContUseInvoiceItems({ + // attachParams, + // cusProduct: curMainProduct!, + // sub: curSub, + // logger, + // }); - await createAndFilterContUseItems({ + const { replaceables } = await createAndFilterContUseItems({ attachParams, curMainProduct: curMainProduct!, sub: curSub, - // interval: config.sameIntervals ? interval : undefined, - // intervalCount: config.sameIntervals ? intervalCount : undefined, logger, }); - console.log("Replaceables: ", replaceables); - if (proration === ProrationBehavior.Immediately) { latestInvoice = await createProrationInvoice({ attachParams, diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts index bb6e211d4..1fd19314f 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts @@ -10,6 +10,11 @@ import { isFixedPrice, isOneOffPrice, } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { + formatReward, + getAmountAfterReward, + getAmountAfterStripeDiscounts, +} from "@/internal/rewards/rewardUtils.js"; import { formatUnixToDate, formatUnixToDateTime } from "@/utils/genUtils.js"; import { @@ -20,7 +25,9 @@ import { Organization, PreviewLineItem, Price, + Reward, } from "@autumn/shared"; +import Stripe from "stripe"; export const priceToNewPreviewItem = ({ org, @@ -32,6 +39,8 @@ export const priceToNewPreviewItem = ({ productQuantity = 1, product, onTrial, + rewards, + subDiscounts, }: { org: Organization; price: Price; @@ -42,6 +51,8 @@ export const priceToNewPreviewItem = ({ productQuantity?: number; product: FullProduct; onTrial?: boolean; + rewards?: Reward[]; + subDiscounts?: Stripe.Discount[]; }) => { if (skipOneOff && isOneOffPrice({ price })) return; @@ -56,12 +67,15 @@ export const priceToNewPreviewItem = ({ intervalCount: price.config.interval_count || 1, }); - // if (finalProration) { - // console.log("Start: ", formatUnixToDateTime(finalProration.start)); - // console.log("End: ", formatUnixToDateTime(finalProration.end)); - // } - // console.log("Now: ", formatUnixToDateTime(now)); - // console.log("--------------------------------"); + const applyRewards = rewards?.filter( + (r) => + r.discount_config?.price_ids?.includes(price.id) || + r.discount_config?.apply_to_all + ); + + for (const reward of applyRewards ?? []) { + console.log("Apply Reward", formatReward({ reward })); + } if (isFixedPrice({ price })) { let amount = priceToInvoiceAmount({ @@ -76,6 +90,28 @@ export const priceToNewPreviewItem = ({ amount = 0; } + for (const reward of applyRewards ?? []) { + amount = getAmountAfterReward({ + amount, + reward, + subDiscounts: subDiscounts ?? [], + }); + } + + // console.log( + // "Discounts: ", + // subDiscounts?.map((d) => ({ + // id: d.id, + // coupon: d.coupon, + // })) + // ); + amount = getAmountAfterStripeDiscounts({ + price, + amount, + product, + stripeDiscounts: subDiscounts ?? [], + }); + let description = newPriceToInvoiceDescription({ org, price, diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts index ab6a837c5..3aafa6e6e 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts @@ -15,34 +15,72 @@ import { isTrialing } from "../../cusProducts/cusProductUtils.js"; import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { + formatPrice, getPriceEntitlement, getPriceOptions, } from "@/internal/products/prices/priceUtils.js"; -import { cusProductToEnts } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { + cusProductToEnts, + cusProductToProduct, +} from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { + getAmountAfterStripeDiscounts, + getUnusedAmountAfterDiscount, +} from "@/internal/rewards/rewardUtils.js"; +import { Decimal } from "decimal.js"; +const getDiscountsApplied = ({ + invoiceItem, + subDiscounts, +}: { + invoiceItem?: Stripe.InvoiceLineItem; + subDiscounts?: Stripe.Discount[]; +}) => { + if (!invoiceItem || !subDiscounts) return []; + const discountsApplied: Stripe.Discount[] = []; + for (const dAmount of invoiceItem?.discount_amounts || []) { + const discount = subDiscounts?.find((d) => d.id == dAmount.discount); + if (discount && dAmount.amount > 0) { + // console.log("Discount applied: ", discount.id); + // console.log("Amount off: ", dAmount.amount); + discountsApplied.push(discount); + } + } + return discountsApplied; +}; export const priceToUnusedPreviewItem = ({ price, stripeItems, cusProduct, now, org, + subDiscounts, + latestInvoice, }: { price: Price; stripeItems: Stripe.SubscriptionItem[]; cusProduct: FullCusProduct; now?: number; org?: Organization; + subDiscounts?: Stripe.Discount[]; + latestInvoice: Stripe.Invoice; }) => { now = now || Date.now(); const onTrial = isTrialing({ cusProduct, now }); - // 1. Get price from stripe items const subItem = findStripeItemForPrice({ price, stripeItems, stripeProdId: cusProduct?.product.processor?.id, }) as Stripe.SubscriptionItem | undefined; + const invoiceItem = findStripeItemForPrice({ + price, + invoiceLineItems: latestInvoice.lines.data, + stripeProdId: cusProduct?.product.processor?.id, + }) as Stripe.InvoiceLineItem | undefined; + if (!subItem) return undefined; const ents = cusProductToEnts({ cusProduct }); @@ -50,10 +88,14 @@ export const priceToUnusedPreviewItem = ({ const options = getPriceOptions(price, cusProduct.options); const config = price.config as UsagePriceConfig; - const quantity = notNullish(options?.quantity) + let quantity = notNullish(options?.quantity) ? options?.quantity! * config.billing_units! : 1; + if (isFixedPrice({ price })) { + quantity = cusProduct.quantity || 1; + } + const finalProration = getProration({ now, interval: price.config.interval!, @@ -63,7 +105,7 @@ export const priceToUnusedPreviewItem = ({ : undefined, })!; - const amount = onTrial + let amount = onTrial ? 0 : -priceToInvoiceAmount({ price, @@ -73,6 +115,30 @@ export const priceToUnusedPreviewItem = ({ now, }); + console.log("Invoice item qty: ", invoiceItem?.quantity); + // console.log( + // "Sub discounts: ", + // subDiscounts?.map((d) => d.id) + // ); + + // const discountsApplied = getDiscountsApplied({ + // invoiceItem, + // subDiscounts, + // }); + + // console.log("Discounts applied: ", discountsApplied); + + const ratio = new Decimal(quantity) + .div(invoiceItem?.quantity || 1) + .toNumber(); + console.log("Ratio: ", ratio); + console.log("Discount amounts: ", invoiceItem?.discount_amounts); + amount = -getUnusedAmountAfterDiscount({ + amount, + discountAmounts: invoiceItem?.discount_amounts || [], + ratio, + }); + let description = priceToInvoiceDescription({ price, org, @@ -101,17 +167,4 @@ export const priceToUnusedPreviewItem = ({ price_id: price.id!, feature_id: ent?.feature.id, }; - - // return { - // // quantity: 1, - // amount, - // subItem, - // }; - // const subItem = stripeItems.find((si) => { - // const config = price.config as UsagePriceConfig; - - // return config.stripe_price_id == si.price?.id; - // }); - - // return subItem; }; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index 8fe8e7a8b..c977573a8 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -23,7 +23,7 @@ export const getAttachParams = async ({ customPrices, customEnts, stripeVars, - reward, + rewards, } = await processAttachBody({ req, attachBody, @@ -58,7 +58,7 @@ export const getAttachParams = async ({ entitlements, freeTrial, replaceables: [], - reward, + rewards, // From req req, org: req.org, diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts index 1257c9e0b..b2c666f8e 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts @@ -9,7 +9,7 @@ import RecaseError from "@/utils/errorUtils.js"; import { ErrCode } from "@autumn/shared"; import Stripe from "stripe"; -export const getReward = async ({ +export const getRewards = async ({ req, attachBody, stripeCli, @@ -19,32 +19,47 @@ export const getReward = async ({ stripeCli: Stripe; }) => { const { reward: idOrCode } = attachBody; + if (!idOrCode) { return undefined; } + const rewardArray = typeof idOrCode === "string" ? [idOrCode] : idOrCode; + + if (rewardArray.length === 0) { + return undefined; + } + // 1. Get reward by id or promo code - const reward = await RewardService.getByIdOrCode({ + const rewards = await RewardService.getByIdOrCode({ db: req.db, - idOrCode, + codes: rewardArray, orgId: req.org.id, env: req.env, }); - if (!reward) { - throw new RecaseError({ - message: `Reward ${idOrCode} not found`, - code: ErrCode.RewardNotFound, - statusCode: 404, - }); + for (const reward of rewardArray) { + const corresponding = rewards.find( + (r) => r.id === reward || r.promo_codes.some((c) => c.code === reward) + ); + + if (!corresponding) { + throw new RecaseError({ + message: `Reward ${reward} not found`, + code: ErrCode.RewardNotFound, + statusCode: 404, + }); + } } - const stripeCoupon = await stripeCli.coupons.retrieve(reward.id); + return rewards; - return { - reward, - stripeCoupon, - }; + // const stripeCoupon = await stripeCli.coupons.retrieve(reward.id); + + // return { + // reward, + // stripeCoupon, + // }; }; export const processAttachBody = async ({ @@ -73,7 +88,7 @@ export const processAttachBody = async ({ customer, logger: req.logtail, }), - getReward({ + getRewards({ req, attachBody, stripeCli, @@ -99,7 +114,7 @@ export const processAttachBody = async ({ return { customer, products, - reward: rewardData?.reward, + rewards: rewardData, optionsList, prices, entitlements, diff --git a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts index 3bbec793f..ae1f3bd96 100644 --- a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts @@ -150,7 +150,11 @@ export const getCustomerSub = async ({ // } const sub = await stripeCli.subscriptions.retrieve(subId, { - expand: ["items.data.price.tiers"], + expand: [ + "items.data.price.tiers", + "discounts.coupon.applies_to", + "latest_invoice", + ], }); return { subId, sub, cusProduct }; diff --git a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts index c3bf30f4e..5a130f9c4 100644 --- a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts @@ -103,7 +103,6 @@ export const previewToCheckoutRes = async ({ let nextCycle = undefined; if (notNullish(preview.due_next_cycle)) { - console.log("New product items:", newProduct.items); let total = newProduct.items .reduce((acc, item) => { if (item.usage_model == UsageModel.PayPerUse) { diff --git a/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts index e666ed31d..e93674046 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts @@ -20,6 +20,7 @@ import { Decimal } from "decimal.js"; import { notNullish } from "@/utils/genUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; +import Stripe from "stripe"; export const getMultiAttachPreview = async ({ req, @@ -62,6 +63,8 @@ export const getMultiAttachPreview = async ({ cusProduct, now: attachParams.now!, org: attachParams.org, + latestInvoice: sub?.latest_invoice as Stripe.Invoice, + subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[], }); if (!previewLineItem) continue; @@ -69,8 +72,6 @@ export const getMultiAttachPreview = async ({ items.push(previewLineItem); } - console.log("old items: ", items); - const productList = attachParams.productsList!; const newItems: PreviewLineItem[] = []; const itemsWithoutTrial: PreviewLineItem[] = []; @@ -94,6 +95,9 @@ export const getMultiAttachPreview = async ({ const onTrial = notNullish(attachParams?.freeTrial) || sub?.status == "trialing"; + + // How to tell if sub discount will apply to a certain price... + for (const price of product.prices) { const newItem = priceToNewPreviewItem({ org: attachParams.org, @@ -105,6 +109,8 @@ export const getMultiAttachPreview = async ({ productQuantity: productOptions.quantity ?? 1, product, onTrial, + rewards: attachParams.rewards, + subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[], }); const noTrialItem = priceToNewPreviewItem({ org: attachParams.org, @@ -116,6 +122,8 @@ export const getMultiAttachPreview = async ({ productQuantity: productOptions.quantity ?? 1, product, onTrial: false, + rewards: attachParams.rewards, + subDiscounts: (sub?.discounts ?? []) as Stripe.Discount[], }); if (newItem) { @@ -148,8 +156,6 @@ export const getMultiAttachPreview = async ({ }; } } - // console.log("dueNextCycle", dueNextCycle); - // console.log("itemsWithoutTrial", itemsWithoutTrial); return { // items, diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts index d3a5b691c..2caf29c57 100644 --- a/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts +++ b/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts @@ -294,6 +294,7 @@ export const paramsToScheduleItems = async ({ const items = phase.items.map((item) => ({ price: (item.price as Stripe.Price).id, quantity: item.quantity, + trial_end: phase.trial_end || undefined, })); const isLast = index === schedule!.phases.length - 1; const end_date = isLast ? billingPeriodEnd : phase.end_date; diff --git a/server/src/internal/customers/attach/mergeUtils/phaseUtils/upsertNewPhase.ts b/server/src/internal/customers/attach/mergeUtils/phaseUtils/upsertNewPhase.ts index 84c90aa80..d84784aed 100644 --- a/server/src/internal/customers/attach/mergeUtils/phaseUtils/upsertNewPhase.ts +++ b/server/src/internal/customers/attach/mergeUtils/phaseUtils/upsertNewPhase.ts @@ -38,6 +38,7 @@ export const preparePhasesForBillingPeriod = ({ })), start_date: phase.start_date, end_date: phase.end_date, + trial_end: phase.trial_end || undefined, })); const shouldInsert = !phaseAndUnixMatch({ diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 7c52c59fa..783650607 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -27,7 +27,7 @@ export type AttachParams = { stripeCus?: Stripe.Customer; now?: number; paymentMethod: Stripe.PaymentMethod | null | undefined; - reward?: Reward; + rewards?: Reward[]; org: Organization; // customer: Customer; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index ec9960ff5..2c9684bd5 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -187,7 +187,9 @@ export const cusProductToSub = async ({ if (!subId) { return undefined; } - const sub = await stripeCli.subscriptions.retrieve(subId); + const sub = await stripeCli.subscriptions.retrieve(subId, { + expand: ["items.data.price.tiers", "discounts.coupon.applies_to"], + }); return sub; }; diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 02e2c7abf..25dd891dd 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -24,6 +24,7 @@ import { CusReadService } from "./CusReadService.js"; import { StatusCodes } from "http-status-codes"; import { cusProductToProduct } from "./cusProducts/cusProductUtils/convertCusProduct.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; +import { getCustomerSub } from "./attach/attachUtils/convertAttachParams.js"; export const cusRouter: Router = Router(); @@ -370,3 +371,39 @@ cusRouter.get( } } ); + +cusRouter.get("/:customer_id/sub", async (req: any, res: any) => { + try { + const { org, env, db } = req; + const { customer_id } = req.params; + const orgId = req.orgId; + + const fullCus = await CusService.getFull({ + db, + orgId, + env, + idOrInternalId: customer_id, + }); + + const subId = fullCus.customer_products.flatMap( + (cp: FullCusProduct) => cp.subscription_ids || [] + )?.[0]; + + if (!subId) { + throw new RecaseError({ + message: "Customer has no active subscription", + code: "CUSTOMER_NO_ACTIVE_SUBSCRIPTION", + statusCode: StatusCodes.NOT_FOUND, + }); + } + + const stripeCli = createStripeCli({ org, env }); + const sub = await stripeCli.subscriptions.retrieve(subId, { + expand: ["discounts.coupon"], + }); + + res.status(200).json({ sub }); + } catch (error) { + handleFrontendReqError({ req, error, res, action: "get customer rewards" }); + } +}); diff --git a/server/src/internal/invoices/prorationUtils.ts b/server/src/internal/invoices/prorationUtils.ts index f3ab8c4eb..d80de758b 100644 --- a/server/src/internal/invoices/prorationUtils.ts +++ b/server/src/internal/invoices/prorationUtils.ts @@ -22,11 +22,6 @@ export const calculateProrationAmount = ({ const num = new Decimal(periodEnd).minus(now); const denom = new Decimal(periodEnd).minus(periodStart); - console.log(`Period end:`, formatUnixToDate(periodEnd)); - console.log(`Period start:`, formatUnixToDate(periodStart)); - console.log(`Now:`, formatUnixToDate(now)); - console.log(`Amount:`, amount); - const proratedAmount = num.div(denom).mul(amount); if (proratedAmount.lte(0) && !allowNegative) { diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index 00a2e79fb..000324792 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -209,8 +209,6 @@ export const handleCreateProduct = async (req: Request, res: any) => entitlements = res.entitlements; } - console.log("Free trial:", freeTrial); - await initProductInStripe({ db, product: { diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 39baea229..52172cada 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -387,3 +387,24 @@ productRouter.get("/:productId/info", async (req: any, res: any) => { }); } }); + +productRouter.get("/rewards", async (req: any, res: any) => { + try { + const { db, orgId, env } = req; + + const rewards = await RewardService.list({ + db, + orgId, + env, + }); + + res.status(200).send({ rewards }); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get rewards", + }); + } +}); diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index d0b902b04..14a9167d2 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -15,6 +15,7 @@ import { TierInfinite, OnIncrease, OnDecrease, + Product, } from "@autumn/shared"; import RecaseError from "@/utils/errorUtils.js"; @@ -426,10 +427,20 @@ export const roundUsage = ({ .toNumber(); }; -export const formatPrice = ({ price }: { price: Price }) => { +export const formatPrice = ({ + price, + product, +}: { + price: Price; + product?: Product; +}) => { if (price.config.type == PriceType.Fixed) { const config = price.config as FixedPriceConfig; - return `${config.amount}${config.interval == BillingInterval.OneOff ? "(one off)" : `/ ${config.interval}`}`; + const formatted = `${config.amount}${config.interval == BillingInterval.OneOff ? "(one off)" : `/ ${config.interval}`}`; + if (product) { + return `${product.name} - ${formatted}`; + } + return formatted; } else { const config = price.config as UsagePriceConfig; let billingType = getBillingType(config); @@ -441,6 +452,10 @@ export const formatPrice = ({ price }: { price: Price }) => { let featureId = config.feature_id; - return `${formatBillingType[billingType as keyof typeof formatBillingType]} price for feature ${featureId}: $${config.usage_tiers[0].amount}${config.billing_units ? ` ${config.billing_units}` : ""}`; + const formatted = `${formatBillingType[billingType as keyof typeof formatBillingType]} price for feature ${featureId}: $${config.usage_tiers[0].amount}${config.billing_units ? ` ${config.billing_units}` : ""}`; + if (product) { + return `${product.name} - ${formatted}`; + } + return formatted; } }; diff --git a/server/src/internal/rewards/RewardService.ts b/server/src/internal/rewards/RewardService.ts index 630072b4c..e5a0f1e1e 100644 --- a/server/src/internal/rewards/RewardService.ts +++ b/server/src/internal/rewards/RewardService.ts @@ -35,34 +35,32 @@ export class RewardService { static async getByIdOrCode({ db, - idOrCode, + codes, orgId, env, }: { db: DrizzleCli; - idOrCode: string; + codes: string[]; orgId: string; env: AppEnv; }) { - let reward = await db.query.rewards.findFirst({ + let reward = await db.query.rewards.findMany({ where: and( eq(rewards.org_id, orgId), eq(rewards.env, env), or( - eq(rewards.id, idOrCode), - sql`EXISTS ( + inArray(rewards.id, codes), + ...codes.map( + (code) => sql`EXISTS ( SELECT 1 FROM unnest("promo_codes") AS elem - WHERE elem->>'code' = ${idOrCode} + WHERE elem->>'code' = ${code} )` + ) ) ), }); - if (!reward) { - return null; - } - - return reward as Reward; + return reward as Reward[]; } static async insert({ diff --git a/server/src/internal/rewards/rewardUtils.ts b/server/src/internal/rewards/rewardUtils.ts index 4137fecf5..94e6ba998 100644 --- a/server/src/internal/rewards/rewardUtils.ts +++ b/server/src/internal/rewards/rewardUtils.ts @@ -16,6 +16,10 @@ import { ProductService } from "../products/ProductService.js"; import { initProductInStripe } from "../products/productUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import Stripe from "stripe"; +import { Decimal } from "decimal.js"; +import { isFixedPrice } from "../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { formatPrice } from "../products/prices/priceUtils.js"; export const constructReward = ({ internalId, @@ -166,3 +170,131 @@ export const initRewardStripePrices = async ({ } return; }; + +export const formatReward = ({ reward }: { reward: Reward }) => { + if (!reward) return ""; + const discountString = + reward.type == RewardType.PercentageDiscount + ? `${reward.discount_config?.discount_value}%` + : `${reward.discount_config?.discount_value} off`; + + if (reward.discount_config?.apply_to_all) { + return `${discountString} off all products`; + } else if (reward.discount_config?.price_ids) { + return `${discountString} off prices: ${reward.discount_config?.price_ids.join(", ")}`; + } + + return discountString; +}; + +export const getAmountAfterReward = ({ + amount, + reward, + subDiscounts, +}: { + amount: number; + reward: Reward; + subDiscounts: Stripe.Discount[]; +}) => { + if (subDiscounts.find((d) => d.coupon?.id === reward.id)) { + return amount; + } + + if (reward.type === RewardType.PercentageDiscount) { + const discountValue = new Decimal( + reward.discount_config?.discount_value ?? 0 + ); + const discountRatio = new Decimal(1).minus(discountValue.div(100)); + return new Decimal(amount).mul(discountRatio).toNumber(); + } else if (reward.type === RewardType.FixedDiscount) { + const discountAmount = new Decimal( + reward.discount_config?.discount_value ?? 0 + ); + return new Decimal(amount).minus(discountAmount).toNumber(); + } + return amount; +}; + +export const discountAppliesToPrice = ({ + discount, + product, + price, +}: { + discount: Stripe.Discount; + product: Product; + price: Price; +}) => { + const appliesTo = discount.coupon?.applies_to?.products; + + if (nullish(appliesTo)) return true; + + if (isFixedPrice({ price })) { + return appliesTo!.some( + (stripeProdId) => stripeProdId === product.processor?.id + ); + } + + return appliesTo!.some( + (stripeProdId) => stripeProdId === price.config.stripe_product_id + ); +}; + +export const getUnusedAmountAfterDiscount = ({ + amount, + discountAmounts, + ratio, +}: { + amount: number; + discountAmounts: any[]; + ratio: number; +}) => { + let amountAfterDiscount = Math.abs(amount); + + for (const discountAmount of discountAmounts) { + const appliedDiscount = new Decimal(discountAmount.amount || 0) + .div(100) + .mul(ratio); + + amountAfterDiscount = new Decimal(amountAfterDiscount) + .minus(appliedDiscount) + .toNumber(); + } + return amountAfterDiscount; +}; + +export const getAmountAfterStripeDiscounts = ({ + price, + amount, + product, + stripeDiscounts, +}: { + price: Price; + product: Product; + amount: number; + stripeDiscounts: Stripe.Discount[]; +}) => { + let amountAfterDiscount = amount; + + for (const discount of stripeDiscounts) { + if (!discountAppliesToPrice({ discount, product, price })) continue; + + console.log( + `Coupon: ${discount.coupon?.id} applies to price (${formatPrice({ price, product })})` + ); + const coupon: Stripe.Coupon = discount.coupon; + if (coupon.percent_off) { + const ratio = new Decimal(1).minus( + new Decimal(coupon.percent_off).div(100) + ); + amountAfterDiscount = new Decimal(amountAfterDiscount) + .mul(ratio) + .toNumber(); + } else if (coupon.amount_off) { + // must do some ratio ting here... + amountAfterDiscount = new Decimal(amountAfterDiscount) + .minus(new Decimal(coupon.amount_off).div(100)) + .toNumber(); + } + } + return amountAfterDiscount; +}; diff --git a/server/tests/core/multiAttach/multiAttach1.test.ts b/server/tests/core/multiAttach/multiAttach1.test.ts index f4d0bdc82..b3f886c5d 100644 --- a/server/tests/core/multiAttach/multiAttach1.test.ts +++ b/server/tests/core/multiAttach/multiAttach1.test.ts @@ -28,7 +28,7 @@ import { advanceTestClock, completeCheckoutForm, } from "tests/utils/stripeUtils.js"; -import { addWeeks } from "date-fns"; +import { addDays, addWeeks } from "date-fns"; import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; import { expectMultiAttachCorrect } from "tests/utils/expectUtils/expectMultiAttach.js"; @@ -75,7 +75,7 @@ const ops = [ ]; const testCase = "multiAttach1"; -describe(`${chalk.yellowBright("multiAttach1: Testing multi attach for trial products")}`, () => { +describe(`${chalk.yellowBright("multiAttach1: Testing multi attach for trial products and update product quantities mid trial")}`, () => { let customerId = testCase; let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); @@ -115,49 +115,88 @@ describe(`${chalk.yellowBright("multiAttach1: Testing multi attach for trial pro db, org, env, - attachPm: "success", + // attachPm: "success", }); testClockId = testClockId1!; }); - it("should run multi attach and have correct sub", async function () { + it("should run multi attach through checkout and have correct sub", async function () { const productsList = [ { product_id: pro.id, quantity: 5, product: pro, - status: CusProductStatus.Active, + status: CusProductStatus.Trialing, }, { product_id: premium.id, quantity: 3, product: premium, - status: CusProductStatus.Active, + status: CusProductStatus.Trialing, }, { product_id: growth.id, quantity: 2, product: growth, - status: CusProductStatus.Active, + status: CusProductStatus.Trialing, }, ]; await expectMultiAttachCorrect({ customerId, products: productsList, + results: productsList, db, org, env, }); + }); - // const { checkout_url } = await autumn.attach({ - // customer_id: customerId, - // // @ts-ignore - // products: productsList, - // force_checkout: true, - // }); + it("should advance clock and update premium & growth while trialing", async function () { + const newProducts = [ + { + product_id: premium.id, + quantity: 1, + }, + { + product_id: growth.id, + quantity: 5, + }, + ]; - // await completeCheckoutForm(checkout_url); + const results = [ + { + product: pro, + quantity: 5, + status: CusProductStatus.Trialing, + }, + { + product: premium, + quantity: 1, + status: CusProductStatus.Trialing, + }, + + { + product: growth, + quantity: 5, + status: CusProductStatus.Trialing, + }, + ]; + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 3).getTime(), + }); + + await expectMultiAttachCorrect({ + customerId, + products: newProducts, + results, + db, + org, + env, + }); }); }); diff --git a/server/tests/core/multiAttach/multiAttach2.test.ts b/server/tests/core/multiAttach/multiAttach2.test.ts new file mode 100644 index 000000000..ae3013489 --- /dev/null +++ b/server/tests/core/multiAttach/multiAttach2.test.ts @@ -0,0 +1,189 @@ +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, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expectMultiAttachCorrect } from "tests/utils/expectUtils/expectMultiAttach.js"; + +let growth = constructProduct({ + id: "growth", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), + ], + type: "growth", +}); + +let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", + trial: true, +}); + +let pro = constructProduct({ + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 300, + }), + ], + type: "pro", + trial: true, +}); + +const ops = [ + { + entityId: "1", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, +]; + +const testCase = "multiAttach2"; +describe(`${chalk.yellowBright("multiAttach2: Testing multi attach for trial products and update product quantities mid trial")}`, () => { + let customerId = testCase; + 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, premium, growth], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium, growth], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 5, + product: pro, + status: CusProductStatus.Trialing, + }, + { + product_id: premium.id, + quantity: 3, + product: premium, + status: CusProductStatus.Trialing, + }, + { + product_id: growth.id, + quantity: 2, + product: growth, + status: CusProductStatus.Trialing, + }, + ]; + + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + }); + }); + + it("should advance clock and update premium & growth while trialing", async function () { + const newProducts = [ + { + product_id: premium.id, + quantity: 1, + }, + { + product_id: growth.id, + quantity: 5, + }, + ]; + + const results = [ + { + product: pro, + quantity: 5, + status: CusProductStatus.Trialing, + }, + { + product: premium, + quantity: 1, + status: CusProductStatus.Trialing, + }, + + { + product: growth, + quantity: 5, + status: CusProductStatus.Trialing, + }, + ]; + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 3).getTime(), + }); + + await expectMultiAttachCorrect({ + customerId, + products: newProducts, + results, + db, + org, + env, + }); + }); +}); diff --git a/server/tests/core/multiAttach/multiAttach3.test.ts b/server/tests/core/multiAttach/multiAttach3.test.ts new file mode 100644 index 000000000..d0af5514b --- /dev/null +++ b/server/tests/core/multiAttach/multiAttach3.test.ts @@ -0,0 +1,252 @@ +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, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { + expectMultiAttachCorrect, + expectResultsCorrect, +} from "tests/utils/expectUtils/expectMultiAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expect } from "chai"; + +let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", + trial: true, +}); + +let pro = constructProduct({ + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 300, + }), + ], + type: "pro", + trial: true, +}); + +const testCase = "multiAttach3"; +describe(`${chalk.yellowBright("multiAttach3: Testing multi attach for trial products transfer to entity, then cancel products on entities...")}`, () => { + let customerId = testCase; + 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, premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 5, + product: pro, + status: CusProductStatus.Trialing, + }, + { + product_id: premium.id, + quantity: 3, + product: premium, + status: CusProductStatus.Trialing, + }, + ]; + + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + }); + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + const results = [ + { + product: pro, + quantity: 4, + status: CusProductStatus.Trialing, + }, + { + product: premium, + quantity: 2, + status: CusProductStatus.Trialing, + }, + { + product: pro, + quantity: 1, + entityId: "2", + status: CusProductStatus.Trialing, + }, + { + product: pro, + quantity: 1, + entityId: "2", + status: CusProductStatus.Trialing, + }, + ]; + + it("should transfer to entity and have correct sub", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.transfer(customerId, { + to_entity_id: "2", + product_id: pro.id, + }); + await autumn.transfer(customerId, { + to_entity_id: "1", + product_id: premium.id, + }); + + await expectResultsCorrect({ + customerId, + results, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); + + it("should cancel one entity's sub at end of cycle and have correct schedule...", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: "2", + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); + + it("should cancel one entity's sub immediately", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: premium.id, + entity_id: "1", + cancel_immediately: true, + // @ts-ignore + prorate: false, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); + + it("should advance test clock to end of trial and have correct sub", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 8).getTime(), + waitForSeconds: 30, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + + const customer = await autumn.customers.get(customerId); + const latestInvoice = customer.invoices[0]; + + // Should only have paid for 4 pro and 2 premium... + const invoiceTotal = + getBasePrice({ product: pro }) * 4 + + getBasePrice({ product: premium }) * 2; + + expect(invoiceTotal).to.equal(latestInvoice.total); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); +}); diff --git a/server/tests/core/multiAttach/multiReward/multiReward1.test.ts b/server/tests/core/multiAttach/multiReward/multiReward1.test.ts new file mode 100644 index 000000000..8352c4a33 --- /dev/null +++ b/server/tests/core/multiAttach/multiReward/multiReward1.test.ts @@ -0,0 +1,151 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { expectMultiAttachCorrect } from "tests/utils/expectUtils/expectMultiAttach.js"; +import { + multiRewardPremium, + multiRewardPro, + premiumReward, + proReward, + setupMultiRewardBefore, +} from "./multiRewardUtils.test.js"; + +const testCase = "multiReward1"; +describe(`${chalk.yellowBright("multiReward1: Testing multi attach with rewards")}`, () => { + let customerId = testCase; + 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; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + await setupMultiRewardBefore({ + orgId: org.id, + db, + env, + }); + + // addPrefixToProducts({ + // products: [pro, premium, growth], + // prefix: testCase, + // }); + + // await createProducts({ + // autumn: autumnJs, + // products: [pro, premium, growth], + // db, + // orgId: org.id, + // env, + // customerId, + // }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: multiRewardPro.id, + quantity: 3, + product: multiRewardPro, + status: CusProductStatus.Active, + }, + { + product_id: multiRewardPremium.id, + quantity: 3, + product: multiRewardPremium, + status: CusProductStatus.Active, + }, + ]; + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + rewards: [proReward.id, premiumReward.id], + expectedRewards: [proReward.id, premiumReward.id], + }); + }); + return; + + // it("should advance clock and update premium & growth while trialing", async function () { + // const newProducts = [ + // { + // product_id: premium.id, + // quantity: 1, + // }, + // { + // product_id: growth.id, + // quantity: 5, + // }, + // ]; + + // const results = [ + // { + // product: pro, + // quantity: 5, + // status: CusProductStatus.Trialing, + // }, + // { + // product: premium, + // quantity: 1, + // status: CusProductStatus.Trialing, + // }, + + // { + // product: growth, + // quantity: 5, + // status: CusProductStatus.Trialing, + // }, + // ]; + + // await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addDays(new Date(), 3).getTime(), + // }); + + // await expectMultiAttachCorrect({ + // customerId, + // products: newProducts, + // results, + // db, + // org, + // env, + // }); + // }); +}); diff --git a/server/tests/core/multiAttach/multiReward/multiReward2.test.ts b/server/tests/core/multiAttach/multiReward/multiReward2.test.ts new file mode 100644 index 000000000..22edf2df7 --- /dev/null +++ b/server/tests/core/multiAttach/multiReward/multiReward2.test.ts @@ -0,0 +1,189 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { expectMultiAttachCorrect } from "tests/utils/expectUtils/expectMultiAttach.js"; +import { + multiRewardPremium, + multiRewardPro, + premiumReward, + proReward, + setupMultiRewardBefore, +} from "./multiRewardUtils.test.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; + +const testCase = "multiReward2"; +describe(`${chalk.yellowBright("multiReward2: Testing multi attach with rewards -- delete reward and prorate")}`, () => { + let customerId = testCase; + 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; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + await setupMultiRewardBefore({ + orgId: org.id, + db, + env, + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: multiRewardPro.id, + quantity: 3, + product: multiRewardPro, + status: CusProductStatus.Active, + }, + { + product_id: multiRewardPremium.id, + quantity: 3, + product: multiRewardPremium, + status: CusProductStatus.Active, + }, + ]; + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + rewards: [proReward.id, premiumReward.id], + expectedRewards: [proReward.id, premiumReward.id], + }); + }); + + it("should delete discounts from subscription and prorate correctly", async function () { + const fullCus = await CusService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: customerId, + }); + + const cusProduct = fullCus.customer_products.find( + (cp) => cp.product.id === multiRewardPro.id + ); + const sub = await cusProductToSub({ cusProduct, stripeCli }); + + await stripeCli.subscriptions.update(sub!.id, { + discounts: null, + }); + }); + + it("should update pro quantity and have correct checkout amount", async function () { + const productsList = [ + { + product_id: multiRewardPro.id, + quantity: 5, + }, + ]; + + const results = [ + { + product: multiRewardPro, + quantity: 5, + status: CusProductStatus.Active, + }, + { + product: multiRewardPremium, + quantity: 3, + status: CusProductStatus.Active, + }, + ]; + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results, + db, + org, + env, + expectedRewards: [], + }); + }); + return; + + // it("should advance clock and update premium & growth while trialing", async function () { + // const newProducts = [ + // { + // product_id: premium.id, + // quantity: 1, + // }, + // { + // product_id: growth.id, + // quantity: 5, + // }, + // ]; + + // const results = [ + // { + // product: pro, + // quantity: 5, + // status: CusProductStatus.Trialing, + // }, + // { + // product: premium, + // quantity: 1, + // status: CusProductStatus.Trialing, + // }, + + // { + // product: growth, + // quantity: 5, + // status: CusProductStatus.Trialing, + // }, + // ]; + + // await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addDays(new Date(), 3).getTime(), + // }); + + // await expectMultiAttachCorrect({ + // customerId, + // products: newProducts, + // results, + // db, + // org, + // env, + // }); + // }); +}); diff --git a/server/tests/core/multiAttach/multiReward/multiReward3.test.ts b/server/tests/core/multiAttach/multiReward/multiReward3.test.ts new file mode 100644 index 000000000..15b2fdb70 --- /dev/null +++ b/server/tests/core/multiAttach/multiReward/multiReward3.test.ts @@ -0,0 +1,171 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { expectMultiAttachCorrect } from "tests/utils/expectUtils/expectMultiAttach.js"; +import { + multiRewardPremium, + multiRewardPro, + premiumReward, + premiumTrial, + proReward, + proTrial, + setupMultiRewardBefore, +} from "./multiRewardUtils.test.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { expect } from "chai"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; +import { Decimal } from "decimal.js"; + +const testCase = "multiReward3"; +describe(`${chalk.yellowBright("multiReward3: Testing multi attach with rewards -- advance clock and update pro quantity")}`, () => { + let customerId = testCase; + 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; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + await setupMultiRewardBefore({ + orgId: org.id, + db, + env, + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: proTrial.id, + quantity: 3, + product: proTrial, + status: CusProductStatus.Trialing, + }, + { + product_id: premiumTrial.id, + quantity: 3, + product: premiumTrial, + status: CusProductStatus.Trialing, + }, + ]; + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + rewards: [proReward.id, premiumReward.id], + expectedRewards: [proReward.id, premiumReward.id], + }); + }); + + let checkoutRes: any; + + it("should advance clock and update pro quantity", async function () { + const productsList = [ + { + product_id: proTrial.id, + quantity: 5, + product: proTrial, + status: CusProductStatus.Trialing, + }, + ]; + + const results = [ + { + product: proTrial, + quantity: 5, + status: CusProductStatus.Trialing, + }, + { + product: premiumTrial, + quantity: 3, + status: CusProductStatus.Trialing, + }, + ]; + const res = await expectMultiAttachCorrect({ + customerId, + products: productsList, + results, + db, + org, + env, + rewards: [proReward.id, premiumReward.id], + expectedRewards: [proReward.id, premiumReward.id], + }); + + checkoutRes = res.checkoutRes; + }); + + it("should advance to trial end and have correct quantity", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 12).getTime(), + }); + + await expectSubToBeCorrect({ + customerId, + db, + org, + env, + // sub: curSub, + // cusProduct: curMainProduct, + // results: productsList, + }); + + const customer = await autumn.customers.get(customerId); + const latestInvoice = customer.invoices[0]; + + const checkoutNextCycleTotal = checkoutRes.next_cycle?.total; + const premiumPrice = new Decimal(getBasePrice({ product: premiumTrial })) + .mul(3) + .mul(0.2) + .toNumber(); + + console.log("Premium price: ", premiumPrice); + console.log("Checkout next cycle total: ", checkoutNextCycleTotal); + expect(latestInvoice.total).to.equal( + checkoutRes.next_cycle?.total + premiumPrice + ); + }); +}); diff --git a/server/tests/core/multiAttach/multiReward/multiRewardUtils.test.ts b/server/tests/core/multiAttach/multiReward/multiRewardUtils.test.ts new file mode 100644 index 000000000..1f4132987 --- /dev/null +++ b/server/tests/core/multiAttach/multiReward/multiRewardUtils.test.ts @@ -0,0 +1,144 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { RewardService } from "@/internal/rewards/RewardService.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { + APIVersion, + AppEnv, + CouponDurationType, + CreateReward, + ProductItemInterval, + RewardType, +} from "@autumn/shared"; +import { TestFeature } from "tests/setup/v2Features.js"; + +export let premiumTrial = constructProduct({ + id: "multiReward_premiumTrial", + group: "multiReward", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", + trial: true, +}); + +export let proTrial = constructProduct({ + id: "multiReward_proTrial", + group: "multiReward", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 300 }), + ], + type: "pro", + trial: true, +}); +export let multiRewardPremium = constructProduct({ + id: "multiReward_premium", + group: "multiReward", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", +}); + +export let multiRewardPro = constructProduct({ + id: "multiReward_pro", + group: "multiReward", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 300 }), + ], + type: "pro", +}); + +export const proReward: CreateReward = { + id: "pro_reward", + name: "pro_reward", + promo_codes: [{ code: "pro_reward" }], + type: RewardType.PercentageDiscount, + discount_config: { + discount_value: 50, + duration_type: CouponDurationType.Months, + duration_value: 3, + should_rollover: true, + apply_to_all: false, + price_ids: [proTrial.id], + }, +}; + +export const premiumReward: CreateReward = { + id: "premium_reward", + name: "premium_reward", + promo_codes: [{ code: "premium_reward" }], + type: RewardType.PercentageDiscount, + discount_config: { + discount_value: 80, + duration_type: CouponDurationType.Months, + duration_value: 3, + should_rollover: true, + apply_to_all: false, + }, +}; + +export const setupMultiRewardBefore = async ({ + orgId, + db, + env, +}: { + orgId: string; + db: DrizzleCli; + env: AppEnv; +}) => { + const autumn = new AutumnInt({ version: APIVersion.v1_2 }); + for (const product of [ + proTrial, + premiumTrial, + multiRewardPro, + multiRewardPremium, + ]) { + // let res = await autumn.products.get(product.id); + + try { + await autumn.products.delete(product.id); + } catch (error) { + // console.log("Error deleting product:", error); + } + + try { + await autumn.products.create(product); + } catch (error) {} + } + + const products = await ProductService.listFull({ + db, + orgId, + env, + }); + + const proTrialPrice = products.find((p) => p.id === proTrial.id)?.prices[0]; + const premiumTrialPrice = products.find((p) => p.id === premiumTrial.id) + ?.prices[0]; + const proProduct = products.find((p) => p.id === multiRewardPro.id); + const premiumProduct = products.find((p) => p.id === multiRewardPremium.id); + + const proPriceIds = [proTrialPrice!.id, proProduct!.prices[0]!.id]; + const premiumPriceIds = [ + premiumTrialPrice!.id, + premiumProduct!.prices[0]!.id, + ]; + + for (const reward of [proReward, premiumReward]) { + try { + await autumn.rewards.delete(reward.id); + } catch (error) {} + try { + await autumn.rewards.create({ + ...reward, + discount_config: { + ...reward.discount_config, + price_ids: reward.id == proReward.id ? proPriceIds : premiumPriceIds, + }, + }); + } catch (error) {} + } +}; diff --git a/server/tests/core/multiAttach/multiUpgrade/multiUpgrade1.test.ts b/server/tests/core/multiAttach/multiUpgrade/multiUpgrade1.test.ts new file mode 100644 index 000000000..fd5528466 --- /dev/null +++ b/server/tests/core/multiAttach/multiUpgrade/multiUpgrade1.test.ts @@ -0,0 +1,201 @@ +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, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { + expectMultiAttachCorrect, + expectResultsCorrect, +} from "tests/utils/expectUtils/expectMultiAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; + +let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", +}); + +let pro = constructProduct({ + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 300, + }), + ], + type: "pro", +}); + +const testCase = "multiUpgrade1"; +describe(`${chalk.yellowBright("multiUpgrade1: Testing multi attach and upgrade")}`, () => { + let customerId = testCase; + 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, premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 5, + product: pro, + status: CusProductStatus.Active, + }, + { + product_id: premium.id, + quantity: 3, + product: premium, + status: CusProductStatus.Active, + }, + ]; + + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + }); + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + ]; + + const results = [ + { + product: pro, + quantity: 4, + status: CusProductStatus.Active, + }, + { + product: premium, + quantity: 3, + status: CusProductStatus.Active, + }, + { + product: pro, + quantity: 1, + entityId: "1", + status: CusProductStatus.Active, + }, + ]; + + it("should transfer to entity and have correct sub", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.transfer(customerId, { + to_entity_id: "1", + product_id: pro.id, + }); + + await expectResultsCorrect({ + customerId, + results, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); + + it("should upgrade entity's sub to premium", async function () { + const checkoutRes = await autumn.checkout({ + customer_id: customerId, + product_id: premium.id, + entity_id: "1", + }); + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: "1", + }); + + const entity = await autumn.entities.get(customerId, "1"); + const invoices = entity.invoices[0]; + + expect(invoices.total).to.equal(checkoutRes.total); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + + await expectResultsCorrect({ + customerId, + results, + }); + }); +}); diff --git a/server/tests/core/multiAttach/multiUpgrade/multiUpgrade2.test.ts b/server/tests/core/multiAttach/multiUpgrade/multiUpgrade2.test.ts new file mode 100644 index 000000000..41858e807 --- /dev/null +++ b/server/tests/core/multiAttach/multiUpgrade/multiUpgrade2.test.ts @@ -0,0 +1,211 @@ +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, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { + expectMultiAttachCorrect, + expectResultsCorrect, +} from "tests/utils/expectUtils/expectMultiAttach.js"; +import { expectSubToBeCorrect } from "tests/merged/mergeUtils/expectSubCorrect.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { expect } from "chai"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; + +let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", +}); + +let pro = constructProduct({ + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 300, + }), + ], + type: "pro", +}); + +const testCase = "multiUpgrade2"; +describe(`${chalk.yellowBright("multiUpgrade2: Testing multi attach and update quantities downward")}`, () => { + let customerId = testCase; + 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, premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should run multi attach through checkout and have correct sub", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 5, + product: pro, + status: CusProductStatus.Active, + }, + { + product_id: premium.id, + quantity: 3, + product: premium, + status: CusProductStatus.Active, + }, + ]; + + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results: productsList, + db, + org, + env, + }); + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + ]; + + const results = [ + { + product: pro, + quantity: 4, + status: CusProductStatus.Active, + }, + { + product: premium, + quantity: 3, + status: CusProductStatus.Active, + }, + { + product: pro, + quantity: 1, + entityId: "1", + status: CusProductStatus.Active, + }, + ]; + + it("should transfer to entity and have correct sub", async function () { + await autumn.entities.create(customerId, entities); + + await autumn.transfer(customerId, { + to_entity_id: "1", + product_id: pro.id, + }); + + await expectResultsCorrect({ + customerId, + results, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + }); + }); + + it("should update premium and pro quantities downward", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 3, + }, + { + product_id: premium.id, + quantity: 2, + }, + ]; + + const results = [ + { + product: pro, + quantity: 3, + status: CusProductStatus.Active, + }, + { + product: premium, + quantity: 2, + status: CusProductStatus.Active, + }, + { + product: pro, + quantity: 1, + entityId: "1", + status: CusProductStatus.Active, + }, + ]; + + await expectMultiAttachCorrect({ + customerId, + products: productsList, + results, + db, + org, + env, + }); + }); +}); diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index f7b058b86..52168fdb9 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -137,6 +137,7 @@ export const expectSubToBeCorrect = async ({ shouldBeTrialing = false, flags, subId, + rewards, }: { db: DrizzleCli; customerId: string; @@ -148,6 +149,7 @@ export const expectSubToBeCorrect = async ({ checkNotTrialing?: boolean; }; subId?: string; + rewards?: string[]; }) => { const stripeCli = createStripeCli({ org, env }); const fullCus = await CusService.getFull({ @@ -186,7 +188,7 @@ export const expectSubToBeCorrect = async ({ }); // console.log(`\n\nChecking sub correct`); - let printCusProduct = true; + let printCusProduct = false; if (printCusProduct) { console.log(`\n\nChecking sub correct`); } @@ -293,6 +295,12 @@ export const expectSubToBeCorrect = async ({ withEntity: true, isCheckout: false, apiVersion: APIVersion.v1_4, + productOptions: cusProduct.quantity + ? { + product_id: product.id, + quantity: cusProduct.quantity, + } + : undefined, }); if (options?.upcoming_quantity && res?.lineItem) { @@ -336,13 +344,28 @@ export const expectSubToBeCorrect = async ({ } } - const sub = await stripeCli.subscriptions.retrieve(subId); + const sub = await stripeCli.subscriptions.retrieve(subId, { + expand: ["discounts.coupon"], + }); const actualItems = sub.items.data.map((item: any) => ({ price: item.price.id, quantity: item.quantity || 0, })); + const subCouponIds = sub.discounts?.map( + (discount: any) => discount.coupon.id + ); + if (rewards) { + for (const reward of rewards) { + const corresponding = subCouponIds.find( + (subCouponId: any) => subCouponId === reward + ); + expect(corresponding, `reward ${reward} should be in sub`).to.exist; + } + expect(subCouponIds.length).to.equal(rewards.length); + } + await compareActualItems({ actualItems, expectedItems: supposedSubItems, diff --git a/server/tests/merged/trial/mergedTrial4.test.ts b/server/tests/merged/trial/mergedTrial4.test.ts index ebeb286fa..a26111639 100644 --- a/server/tests/merged/trial/mergedTrial4.test.ts +++ b/server/tests/merged/trial/mergedTrial4.test.ts @@ -19,6 +19,7 @@ import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUti import { advanceTestClock } from "tests/utils/stripeUtils.js"; import { addDays } from "date-fns"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; let premium = constructProduct({ id: "premium", @@ -48,7 +49,7 @@ const ops = [ ]; const testCase = "mergedTrial4"; -describe(`${chalk.yellowBright("mergedTrial4: Testing upgrade to product with trial")}`, () => { +describe(`${chalk.yellowBright("mergedTrial4: Testing cancel immediately on merged sub trial")}`, () => { let customerId = testCase; let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); @@ -120,14 +121,24 @@ describe(`${chalk.yellowBright("mergedTrial4: Testing upgrade to product with tr org, env, entityId: op.entityId, - // checkNotTrialing: true, }); } + }); - // await advanceTestClock({ - // stripeCli, - // testClockId, - // advanceTo: addDays(new Date(), 8).getTime(), - // }); + it("should cancel one of subs immediately and have sub still trialing", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: "2", + cancel_immediately: true, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeTrialing: true, + }); }); }); diff --git a/server/tests/merged/trial/mergedTrial5.test.ts b/server/tests/merged/trial/mergedTrial5.test.ts new file mode 100644 index 000000000..08ceac0a9 --- /dev/null +++ b/server/tests/merged/trial/mergedTrial5.test.ts @@ -0,0 +1,187 @@ +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, + CusProductStatus, + 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 { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays } from "date-fns"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; + +let premium = constructProduct({ + id: "premium", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "premium", + trial: true, +}); + +let pro = constructProduct({ + id: "pro", + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "pro", + trial: true, +}); + +const ops = [ + { + entityId: "1", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "3", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, +]; + +const testCase = "mergedTrial5"; +describe(`${chalk.yellowBright("mergedTrial5: Testing cancel at end of cycle and cancel immediately on merged sub trial")}`, () => { + let customerId = testCase; + 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, premium], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium], + 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, + }, + { + id: "3", + name: "Entity 3", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro trial for entity 1 and entity 2", async function () { + await autumn.entities.create(customerId, entities); + + for (const op of ops) { + await attachAndExpectCorrect({ + autumn, + customerId, + product: op.product, + stripeCli, + db, + org, + env, + entityId: op.entityId, + }); + } + }); + + it("should cancel one sub end of cycle", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: "2", + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeTrialing: true, + }); + }); + + it("should cancel one sub immediately", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: "3", + cancel_immediately: true, + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeTrialing: true, + }); + }); + + it("should cancel last sub at end of cycle", async function () { + await autumn.cancel({ + customer_id: customerId, + product_id: pro.id, + entity_id: "1", + }); + + await expectSubToBeCorrect({ + db, + customerId, + org, + env, + shouldBeTrialing: true, + shouldBeCanceled: true, + }); + }); +}); diff --git a/server/tests/utils/expectUtils/expectAttach.ts b/server/tests/utils/expectUtils/expectAttach.ts index 5eda71841..6f81b85fb 100644 --- a/server/tests/utils/expectUtils/expectAttach.ts +++ b/server/tests/utils/expectUtils/expectAttach.ts @@ -134,7 +134,11 @@ export const attachAndExpectCorrect = async ({ } const productCount = customer.products.reduce((acc: number, p: any) => { - if (product.group == p.group && !p.is_add_on) { + if ( + product.group == p.group && + !p.is_add_on && + (entityId ? p.entity_id == entityId : true) + ) { return acc + 1; } else return acc; }, 0); diff --git a/server/tests/utils/expectUtils/expectMultiAttach.ts b/server/tests/utils/expectUtils/expectMultiAttach.ts index ed5051725..2685e2ea6 100644 --- a/server/tests/utils/expectUtils/expectMultiAttach.ts +++ b/server/tests/utils/expectUtils/expectMultiAttach.ts @@ -5,6 +5,7 @@ import { AppEnv, AttachBranch, CreateEntity, + CreateReward, CusProductStatus, FeatureOptions, Organization, @@ -34,17 +35,23 @@ export const expectMultiAttachCorrect = async ({ customerId, entityId, products, + results, + rewards, + expectedRewards, db, org, env, }: { customerId: string; entityId?: string; - products: { + products: ProductOptions[]; + results: { product: ProductV2; quantity: number; status: CusProductStatus; }[]; + rewards?: string[]; + expectedRewards?: string[]; db: DrizzleCli; org: Organization; env: AppEnv; @@ -52,29 +59,33 @@ export const expectMultiAttachCorrect = async ({ const autumn = new AutumnInt({ version: APIVersion.v1_2 }); const checkoutRes = await autumn.checkout({ customer_id: customerId, - // @ts-ignore products: products, entity_id: entityId, + // @ts-ignore + reward: rewards, }); - for (const prodOption of products) { + const attachRes = await autumn.attach({ + customer_id: customerId, + products: products, + entity_id: entityId, + // @ts-ignore + reward: rewards, + }); + + if (attachRes.checkout_url) { + await completeCheckoutForm(attachRes.checkout_url); + await timeout(5000); + } + + for (const result of results) { let customer; customer = await autumn.customers.get(customerId); - // if (entityId) { - // customer = await autumn.entities.get(customerId, entityId); - // } else { - // } expectProductAttached({ customer, - product: prodOption.product, - status: prodOption.status, - }); - - expectFeaturesCorrect({ - customer, - product: prodOption.product, - productQuantity: prodOption.quantity, + product: result.product, + status: result.status, }); } @@ -87,188 +98,30 @@ export const expectMultiAttachCorrect = async ({ customerId, org, env, + rewards: expectedRewards, }); - // const preview = await autumn.attachPreview({ - // customer_id: customerId, - // product_id: product.id, - // entity_id: entityId, - // ...attachParams, - // }); - - // const checkoutRes = await autumn.checkout({ - // customer_id: customerId, - // product_id: product.id, - // entity_id: entityId, - // options: toSnakeCase(options), - // ...attachParams, - // }); - - // const logCheckoutRes = true; - // if (logCheckoutRes) { - // console.log("Checkout res:"); - // for (const line of checkoutRes.lines) { - // console.log(line.description, line.amount); - // } - // console.log("Total: ", checkoutRes.total); - // console.log("--------------------------------"); - // } - - // const optionsCopy = getCurrentOptions({ - // preview, - // options, - // }); - - // // const total = getAttachTotal({ - // // preview, - // // options, - // // }); - - // const { checkout_url } = await autumn.attach({ - // customer_id: customerId, - // product_id: product.id, - // entity_id: entityId, - // options: toSnakeCase(options), - // ...attachParams, - // }); - - // if (checkout_url) { - // await completeCheckoutForm(checkout_url); - // await timeout(5000); - // } - - // if (waitForInvoice) { - // await timeout(waitForInvoice); - // } - - // let customer; - // if (entityId) { - // customer = await autumn.entities.get(customerId, entityId); - // } else { - // customer = await autumn.customers.get(customerId); - // } - - // const productCount = customer.products.reduce((acc: number, p: any) => { - // if (product.group == p.group && !p.is_add_on) { - // return acc + 1; - // } else return acc; - // }, 0); - - // const branch = preview.branch; - - // if (branch == AttachBranch.Downgrade) { - // expect( - // productCount, - // `customer should only have 2 products (from this group: ${product.group})` - // ).to.equal(2); - // } else { - // expect( - // productCount, - // `customer should only have 1 product (from this group: ${product.group})` - // ).to.equal(1); - // } - - // expectProductAttached({ - // customer, - // product, - // entityId, - // status: - // preview.branch == AttachBranch.Downgrade - // ? CusProductStatus.Scheduled - // : undefined, - // }); - - // const skipInvoiceCheck = - // (preview.branch == AttachBranch.UpdatePrepaidQuantity && - // checkoutRes.total == 0) || - // preview.branch == AttachBranch.Downgrade; - - // const freeProduct = isFreeProductV2({ product }); - // if (!skipInvoiceCheck && !freeProduct) { - // expectInvoicesCorrect({ - // customer, - // first: { - // productId: product.id, - // total: new Decimal(checkoutRes.total).toDecimalPlaces(2).toNumber(), - // }, - // }); - // } - - // if (!skipFeatureCheck && branch !== AttachBranch.Downgrade) { - // expectFeaturesCorrect({ - // customer, - // product, - // usage, - // options: optionsCopy, - - // otherProducts, - // entities, - // }); - // } - - // if (branch == AttachBranch.OneOff) { - // return; - // } - - // if (skipSubCheck) return; - - // await expectSubToBeCorrect({ - // db, - // customerId, - // org, - // env, - // shouldBeCanceled, - // flags: { - // checkNotTrialing, - // }, - // }); - - // // await expectSubItemsCorrect({ - // // stripeCli, - // // customerId, - // // product, - // // db, - // // org, - // // env, - // // isCanceled, - // // entityId, - // // }); - - // // let cus = await autumn.customers.get(customerId); - // // const stripeSubs = await stripeCli.subscriptions.list({ - // // customer: cus.stripe_id!, - // // }); - - // // if (numSubs) { - // // expect(stripeSubs.data.length).to.equal( - // // numSubs, - // // `should have ${numSubs} subscriptions` - // // ); - // // } else { - // // expect(stripeSubs.data.length).to.equal( - // // 1, - // // "should only have 1 subscription" - // // ); - // // } + return { + checkoutRes, + }; }; -export const expectAttachCorrect = async ({ - customer, - product, - entityId, +export const expectResultsCorrect = async ({ + customerId, + results, }: { - customer: Customer; - product: ProductV2; - entityId?: string; + customerId: string; + results: { product: ProductV2; quantity: number; status: CusProductStatus }[]; }) => { - expectProductAttached({ - customer, - product, - entityId, - }); + const autumn = new AutumnInt({ version: APIVersion.v1_2 }); + for (const result of results) { + let customer; + customer = await autumn.customers.get(customerId); - expectFeaturesCorrect({ - customer, - product, - }); + expectProductAttached({ + customer, + product: result.product, + status: result.status, + }); + } }; diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index c393fc913..4da58bb3c 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -19,7 +19,10 @@ export const expectProductAttached = ({ }) => { const cusProducts = customer.products; const finalProductId = productId || product?.id; - const productAttached = cusProducts.find((p) => p.id === finalProductId); + const productAttached = cusProducts.find( + (p) => + p.id === finalProductId && (entityId ? p.entity_id === entityId : true) + ); if (!productAttached) { console.log(`product ${finalProductId} not attached`); diff --git a/shared/models/attachModels/attachBody.ts b/shared/models/attachModels/attachBody.ts index f1282a7f7..e102f941d 100644 --- a/shared/models/attachModels/attachBody.ts +++ b/shared/models/attachModels/attachBody.ts @@ -49,7 +49,7 @@ export const AttachBodySchema = z metadata: z.any().optional(), billing_cycle_anchor: z.number().optional(), checkout_session_params: z.any().optional(), - reward: z.string().optional(), + reward: z.string().or(z.array(z.string())).optional(), invoice: z.boolean().optional(), enable_product_immediately: z.boolean().optional(), finalize_invoice: z.boolean().optional(), diff --git a/vite/src/views/customers/customer/CustomerView.tsx b/vite/src/views/customers/customer/CustomerView.tsx index c1a8eaea7..af51e7b9d 100644 --- a/vite/src/views/customers/customer/CustomerView.tsx +++ b/vite/src/views/customers/customer/CustomerView.tsx @@ -36,6 +36,10 @@ export default function CustomerView({ env }: { env: AppEnv }) { url: `/customers/${customer_id}/referrals`, env, }); + const { data: rewardsData } = useAxiosSWR({ + url: `/products/rewards`, + env, + }); const [setAddCouponOpen] = useState(false); const [entityId, setEntityId] = useState(entityIdParam); @@ -89,6 +93,7 @@ export default function CustomerView({ env }: { env: AppEnv }) { entityId, setEntityId, showEntityView, + rewards: rewardsData?.rewards, }} >