From 0174c80db8375336a0bd874fb216b2f009328055 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sat, 5 Jul 2025 20:45:13 +0100 Subject: [PATCH] feat: improving update quantity with proration configs --- pnpm-lock.yaml | 3 + server/shell/g2.sh | 2 +- server/src/cron.ts | 9 +- .../stripe/stripeSubUtils/createStripeSub.ts | 2 + .../handleInvoiceCreated.ts | 34 +- .../handlePrepaidPrices.ts | 106 ++++++ .../internal/api/entities/getEntityUtils.ts | 3 +- .../src/internal/api/entitled/checkUtils.ts | 46 +++ .../addProductFlow/getMergeCusProduct.ts | 33 ++ .../addProductFlow/handlePaidProduct.ts | 2 + .../handleQuantityUpgrade.ts | 140 ++++++++ .../updateFeatureQuantity.ts | 308 +++++++++++------- .../updateQuantityFlow/updateQuantityFlow.ts | 30 +- .../attach/attachUtils/getAttachBranch.ts | 12 +- .../getContUseItems/getContUseInvoiceItems.ts | 2 + .../attachParamsToPreview.ts | 22 +- .../getNewProductPreview.ts | 41 ++- .../getUpdateQuantityPreview.ts | 220 +++++++++++++ .../getUpgradeProductPreview.ts | 11 +- .../cusProducts/cusEnts/cusEntUtils.ts | 5 +- .../cusProducts/cusPrices/CusPriceService.ts | 8 + .../cusPrices/convertCusPriceUtils.ts | 14 + .../cusProducts/cusPrices/cusPriceUtils.ts | 1 + ...ctRepsonse.ts => getCusProductResponse.ts} | 11 + .../processFullCusProducts.ts | 2 +- .../internal/customers/internalCusRouter.ts | 1 + .../invoiceUtils/createAndFinalizeInvoice.ts | 28 ++ .../previewItemUtils/getItemsForCurProduct.ts | 13 +- .../previewItemUtils/getItemsForNewProduct.ts | 5 +- .../internal/products/prices/priceUtils.ts | 4 + .../prices/priceUtils/priceToInvoiceAmount.ts | 88 ++++- .../product-items/validateProductItems.ts | 13 + .../getProductResponse.ts | 22 ++ .../src/internal/products/productV2Utils.ts | 1 + .../createUpgradeProrationInvoice.ts | 14 +- .../handleProratedDowngrade.ts | 1 + server/src/utils/importUtils/parseCsv.ts | 10 +- server/src/utils/scriptUtils/constructItem.ts | 7 + .../utils/scriptUtils/createTestProducts.ts | 12 +- .../getAll/getAllAutumnCustomers.ts | 197 +++++++++++ server/tests/attach/prepaid/prepaid1.ts | 198 +++++++++++ server/tests/attach/prepaid/prepaid2.ts | 149 +++++++++ server/tests/attach/prepaid/prepaid3.ts | 158 +++++++++ server/tests/attach/prepaid/prepaid4.ts | 148 +++++++++ server/tests/attach/prepaid/prepaid5.ts | 266 +++++++++++++++ server/tests/attach/prepaid/prepaid6.ts | 186 +++++++++++ .../tests/utils/expectUtils/expectAttach.ts | 24 +- .../expectUtils/expectFeaturesCorrect.ts | 6 +- .../tests/utils/expectUtils/expectSubUtils.ts | 21 ++ .../utils/testAttachUtils/testAttachUtils.ts | 71 +++- .../attachModels/attachPreviewModels.ts | 1 + .../cusResModels/cusProductResponse.ts | 8 + .../cusProductModels/cusProductModels.ts | 1 + .../prodItemResponseModels.ts | 2 + .../models/productV2Models/productV2Models.ts | 1 + shared/package.json | 1 + shared/utils/displayUtils.ts | 8 +- vite/src/components/general/AdminHover.tsx | 2 +- vite/src/utils/product/productItemUtils.ts | 2 + .../customer/product/ProductOptions.tsx | 7 + .../components/AttachPreviewDetails.tsx | 18 +- .../attach-preview/DueNextCycle.tsx | 5 +- .../components/attach-preview/DueToday.tsx | 14 +- .../attach-preview/OptionsInput.tsx | 51 +++ .../attach-preview/UpdateQuantity.tsx | 85 +++++ .../customer/product/hooks/useAttachState.tsx | 12 - .../views/products/product/ManageProduct.tsx | 18 +- .../product-item/ProductItemConfig.tsx | 11 - .../proration-config/OnDecreaseSelect.tsx | 30 +- .../proration-config/OnIncreaseSelect.tsx | 9 +- 70 files changed, 2713 insertions(+), 283 deletions(-) create mode 100644 server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts create mode 100644 server/src/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.ts create mode 100644 server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts create mode 100644 server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts create mode 100644 server/src/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.ts rename server/src/internal/customers/cusUtils/cusProductResponseUtils/{getCusProductRepsonse.ts => getCusProductResponse.ts} (95%) create mode 100644 server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts create mode 100644 server/tests/attach/prepaid/prepaid1.ts create mode 100644 server/tests/attach/prepaid/prepaid2.ts create mode 100644 server/tests/attach/prepaid/prepaid3.ts create mode 100644 server/tests/attach/prepaid/prepaid4.ts create mode 100644 server/tests/attach/prepaid/prepaid5.ts create mode 100644 server/tests/attach/prepaid/prepaid6.ts create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/OptionsInput.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61b4d4862..011fb00d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: shared: dependencies: + date-fns: + specifier: ^4.1.0 + version: 4.1.0 decimal.js: specifier: ^10.5.0 version: 10.5.0 diff --git a/server/shell/g2.sh b/server/shell/g2.sh index 99cdf6284..083ef1294 100755 --- a/server/shell/g2.sh +++ b/server/shell/g2.sh @@ -13,4 +13,4 @@ MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \ 'tests/advanced/check/*.ts' \ 'tests/attach/others/*.ts' - +$MOCHA_CMD 'tests/attach/prepaid/*.ts' \ No newline at end of file diff --git a/server/src/cron.ts b/server/src/cron.ts index bb5e120cd..f5730e40a 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -110,6 +110,10 @@ const resetCustomerEntitlement = async ({ // 2. Quantity is from prices... const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices); + if (relatedCusPrice) { + return; + } + const entOptions = getEntOptions( cusEnt.customer_product.options, cusEnt.entitlement, @@ -118,11 +122,10 @@ const resetCustomerEntitlement = async ({ const resetBalance = getResetBalance({ entitlement: cusEnt.entitlement, options: entOptions, - relatedPrice: relatedCusPrice?.price, + relatedPrice: undefined, + // relatedPrice: relatedCusPrice, }); - // 3. Update the next_reset_at for each entitlement - // Handle if entitlement changed to unlimited... let entitlement = cusEnt.entitlement; if (entitlement.allowance_type === AllowanceType.Unlimited) { diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index ecb5a0e25..502d637af 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -63,10 +63,12 @@ export const createStripeSub = async ({ : undefined; const { items, prices, usageFeatures } = itemSet; + let subItems = items.filter( (i: any, index: number) => prices[index].config!.interval !== BillingInterval.OneOff, ); + let invoiceItems = items.filter( (i: any, index: number) => prices[index].config!.interval === BillingInterval.OneOff, diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts index 8dbe80da8..b9fdb466a 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts @@ -28,6 +28,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { getFullStripeInvoice } from "../../stripeInvoiceUtils.js"; import { handleUsagePrices } from "./handleUsagePrices.js"; import { handleContUsePrices } from "./handleContUsePrices.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { handlePrepaidPrices } from "./handlePrepaidPrices.js"; const handleInArrearProrated = async ({ db, @@ -194,10 +196,7 @@ export const sendUsageAndReset = async ({ const price = cusPrice.price; let billingType = getBillingType(price.config); - if ( - billingType !== BillingType.UsageInArrear && - billingType !== BillingType.InArrearProrated - ) { + if (isFixedPrice({ price })) { continue; } @@ -252,24 +251,23 @@ export const sendUsageAndReset = async ({ stripeCli, cusEnts, cusPrice, - // customer, - // org, - // env, invoice, usageSub: usageBasedSub, logger, }); - // await handleInArrearProrated({ - // db, - // cusEnts, - // cusPrice, - // customer, - // org, - // env, - // invoice, - // usageSub: usageBasedSub, - // logger, - // }); + } + + if (billingType == BillingType.UsageInAdvance) { + await handlePrepaidPrices({ + db, + stripeCli, + cusPrice, + cusProduct: activeProduct, + usageSub: usageBasedSub, + customer, + invoice, + logger, + }); } } }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts new file mode 100644 index 000000000..fc8d0f996 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -0,0 +1,106 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { getResetBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { + Customer, + EntInterval, + FeatureOptions, + FullCusProduct, + FullCustomerPrice, +} from "@autumn/shared"; +import Stripe from "stripe"; + +export const handlePrepaidPrices = async ({ + db, + stripeCli, + cusProduct, + cusPrice, + usageSub, + customer, + invoice, + logger, +}: { + db: DrizzleCli; + stripeCli: Stripe; + cusProduct: FullCusProduct; + cusPrice: FullCustomerPrice; + usageSub: Stripe.Subscription; + customer: Customer; + invoice: Stripe.Invoice; + logger: any; +}) => { + const isNewPeriod = invoice.period_start !== usageSub.current_period_start; + if (!isNewPeriod) { + return; + } + + const cusEnt = getRelatedCusEnt({ + cusPrice, + cusEnts: cusProduct.customer_entitlements, + }); + + if (!cusEnt) { + logger.error( + `Tried to handle prepaid price for ${cusPrice.id} (${cusPrice.price.id}) but no cus ent found`, + ); + return; + } + + const options = getEntOptions(cusProduct.options, cusEnt.entitlement); + + const resetBalance = getResetBalance({ + entitlement: cusEnt.entitlement, + options: notNullish(options?.upcoming_quantity) + ? { + feature_id: options?.feature_id!, + quantity: options?.upcoming_quantity!, + } + : options, + relatedPrice: cusPrice.price, + }); + + const ent = cusEnt.entitlement; + + if (notNullish(options?.upcoming_quantity)) { + const newOptions = cusProduct.options.map((o) => { + if (o.feature_id == ent.feature_id) { + return { + ...o, + quantity: o.upcoming_quantity, + upcoming_quantity: undefined, + }; + } + return o; + }); + + await CusProductService.update({ + db, + cusProductId: cusProduct.id, + updates: { + options: newOptions as FeatureOptions[], + }, + }); + } + + if (ent.interval == EntInterval.Lifetime) { + return; + } + + logger.info( + `🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`, + ); + + await CusEntService.update({ + db, + id: cusEnt.id, + updates: { + balance: resetBalance, + adjustment: 0, + next_reset_at: usageSub.current_period_end * 1000, + }, + }); +}; diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index c5fc4174f..8e9611695 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -1,4 +1,5 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; import { CusService } from "@/internal/customers/CusService.js"; import { getCusFeaturesResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.js"; import { processFullCusProducts } from "@/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.js"; @@ -47,7 +48,7 @@ export const getEntityResponse = async ({ idOrInternalId: customerId, orgId: org.id, env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + inStatuses: ACTIVE_STATUSES, withEntities: true, withSubs: true, expand, diff --git a/server/src/internal/api/entitled/checkUtils.ts b/server/src/internal/api/entitled/checkUtils.ts index 3453a9e39..318a87b35 100644 --- a/server/src/internal/api/entitled/checkUtils.ts +++ b/server/src/internal/api/entitled/checkUtils.ts @@ -18,6 +18,9 @@ import { getCheckPreview } from "./getCheckPreview.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; import { notNullish } from "@/utils/genUtils.js"; +import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js"; +import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; +import { Decimal } from "decimal.js"; export const getBooleanEntitledResult = async ({ db, @@ -93,6 +96,7 @@ export const getOptions = ({ proration, now, freeTrial, + cusProduct, }: { prodItems: ProductItem[]; features: Feature[]; @@ -103,6 +107,7 @@ export const getOptions = ({ }; now?: number; freeTrial?: FreeTrial | null; + cusProduct?: FullCusProduct; }) => { now = now || Date.now(); @@ -121,6 +126,7 @@ export const getOptions = ({ proration: finalProration, now, }); + let actualPrice = itemToPriceOrTiers({ item: i, }); @@ -132,6 +138,40 @@ export const getOptions = ({ }; } + const currentOptions = cusProduct?.options.find( + (o) => o.feature_id == i.feature_id, + ); + + let currentQuantity = currentOptions?.quantity; + let prorationAmount = 0; + + if (currentQuantity) { + currentQuantity = currentQuantity * (i.billing_units || 1); + + const curPrice = featureToCusPrice({ + internalFeatureId: currentOptions?.internal_feature_id!, + cusPrices: cusProduct?.customer_prices!, + })?.price; + + const curPriceAmount = priceToInvoiceAmount({ + price: curPrice!, + quantity: currentQuantity, + now, + proration: finalProration, + }); + + const newPriceAmount = priceToInvoiceAmount({ + item: i, + quantity: currentQuantity, + now, + proration: finalProration, + }); + + prorationAmount = new Decimal(newPriceAmount) + .minus(curPriceAmount) + .toNumber(); + } + return { feature_id: i.feature_id, feature_name: features.find((f) => f.id == i.feature_id)?.name, @@ -141,6 +181,12 @@ export const getOptions = ({ full_price: actualPrice?.price, full_tiers: actualPrice?.tiers, + + current_quantity: notNullish(currentQuantity) + ? currentQuantity + : undefined, + proration_amount: prorationAmount, + config: i.config, }; }); }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.ts new file mode 100644 index 000000000..1acb2e7ef --- /dev/null +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.ts @@ -0,0 +1,33 @@ +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AttachConfig, FullProduct, Product, products } from "@autumn/shared"; + +export const getMergeCusProduct = async ({ + attachParams, + products, + config, +}: { + attachParams: AttachParams; + products: FullProduct[]; + config: AttachConfig; +}) => { + const { stripeCli, cusProducts, freeTrial } = attachParams; + + let mergeCusProduct = undefined; + if (!config.disableMerge && !freeTrial) { + mergeCusProduct = cusProducts?.find((cp) => + products.some((p) => p.group == cp.product.group), + ); + } + + let mergeSubs = await getStripeSubs({ + stripeCli, + subIds: mergeCusProduct?.subscription_ids, + }); + + return { + mergeCusProduct, + mergeSubs, + }; +}; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 2692268e2..0386fd3e3 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -12,6 +12,7 @@ import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; +import { formatUnixToDateTime } from "@/utils/genUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { APIVersion, @@ -106,6 +107,7 @@ export const handlePaidProduct = async ({ itemSet, anchorToUnix: billingCycleAnchorUnix, reward: i == 0 ? reward : undefined, + now: attachParams.now, }); let sub = subscription as Stripe.Subscription; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts new file mode 100644 index 000000000..8211c59bf --- /dev/null +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts @@ -0,0 +1,140 @@ +import { + Feature, + FeatureOptions, + FullCusProduct, + FullCustomerPrice, + getFeatureInvoiceDescription, + OnIncrease, + UsagePriceConfig, +} from "@autumn/shared"; + +import { Stripe } from "stripe"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; + +import { + shouldBillNow, + shouldProrate, +} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; +import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; +import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; +import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; +import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { Decimal } from "decimal.js"; + +export const handleQuantityUpgrade = async ({ + req, + attachParams, + cusProduct, + stripeSubs, + oldOptions, + newOptions, + cusPrice, + stripeSub, + subItem, +}: { + req: any; + attachParams: AttachParams; + cusProduct: FullCusProduct; + stripeSubs: Stripe.Subscription[]; + oldOptions: FeatureOptions; + newOptions: FeatureOptions; + cusPrice: FullCustomerPrice; + stripeSub: Stripe.Subscription; + subItem: Stripe.SubscriptionItem; +}) => { + // Manually calculate prorations... + const { features, org, logger, db } = req; + const { stripeCli, now, paymentMethod } = attachParams; + + const difference = new Decimal(newOptions.quantity) + .minus(oldOptions.quantity) + .toNumber(); + + const onIncrease = + cusPrice.price.proration_config?.on_increase || + OnIncrease.ProrateImmediately; + + const prorate = shouldProrate(onIncrease); + + if (prorate) { + const amount = priceToInvoiceAmount({ + price: cusPrice.price, + quantity: difference, + proration: prorate + ? { + start: stripeSub.current_period_start * 1000, + end: stripeSub.current_period_end * 1000, + } + : undefined, + now, + }); + + const config = cusPrice.price.config as UsagePriceConfig; + const billingUnits = config.billing_units; + const feature = features.find( + (f: Feature) => f.internal_id == newOptions.internal_feature_id, + )!; + + const product = cusProductToProduct({ cusProduct }); + const invoiceItem = constructStripeInvoiceItem({ + product, + amount: amount, + org: org, + price: cusPrice.price, + description: getFeatureInvoiceDescription({ + feature: feature, + usage: newOptions.quantity, + billingUnits, + prodName: product.name, + isPrepaid: true, + fromUnix: now, + }), + stripeSubId: stripeSub.id, + stripeCustomerId: stripeSub.customer as string, + periodStart: Math.floor((now || Date.now()) / 1000), + periodEnd: Math.floor(stripeSub.current_period_end * 1000), + }); + + logger.info( + `🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`, + ); + + await stripeCli.invoiceItems.create(invoiceItem); + + if (shouldBillNow(onIncrease)) { + const { invoice: finalInvoice } = await createAndFinalizeInvoice({ + stripeCli, + stripeCusId: stripeSub.customer as string, + stripeSubId: stripeSub.id, + paymentMethod: paymentMethod || null, + }); + } + } + + await stripeCli.subscriptionItems.update(subItem.id, { + quantity: newOptions.quantity, + proration_behavior: "none", + }); + + // Update cus ent + const config = cusPrice.price.config as UsagePriceConfig; + const billingUnits = config.billing_units || 1; + let cusEnt = getRelatedCusEnt({ + cusPrice, + cusEnts: cusProduct.customer_entitlements, + }); + + if (cusEnt) { + const incrementBy = new Decimal(difference).mul(billingUnits).toNumber(); + logger.info( + `🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`, + ); + await CusEntService.increment({ + db, + id: cusEnt.id, + amount: incrementBy, + }); + } +}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts index 2e0731b95..a3305bebd 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts @@ -1,137 +1,221 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; - -import { - autumnToStripeProrationBehavior, - getStripeSubs, - getUsageBasedSub, -} from "@/external/stripe/stripeSubUtils.js"; +import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js"; import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; -import { findPriceForFeature } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; + import RecaseError from "@/utils/errorUtils.js"; import { ErrCode, Feature, + FeatureOptions, FullCusProduct, - FullCustomerEntitlement, + OnDecrease, + OnIncrease, UsagePriceConfig, } from "@autumn/shared"; -import { Decimal } from "decimal.js"; + import { Stripe } from "stripe"; -import { AttachConfig } from "../../models/AttachFlags.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js"; +import { shouldProrate } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; +import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js"; +import { Decimal } from "decimal.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; -export const updateFeatureQuantity = async ({ - db, - stripeCli, +const onDecreaseToStripeProration: Record = { + [OnDecrease.ProrateImmediately]: "always_invoice", + [OnDecrease.ProrateNextCycle]: "create_prorations", + [OnDecrease.Prorate]: "create_prorations", + [OnDecrease.None]: "none", +}; + +const handleQuantityDowngrade = async ({ + req, + attachParams, cusProduct, - optionsToUpdate, - config, - logger, + stripeSubs, + oldOptions, + newOptions, + subItem, }: { - db: DrizzleCli; - stripeCli: Stripe; + req: any; + attachParams: AttachParams; cusProduct: FullCusProduct; - optionsToUpdate: any[]; - config: AttachConfig; - logger: any; + stripeSubs: Stripe.Subscription[]; + oldOptions: FeatureOptions; + newOptions: FeatureOptions; + subItem: Stripe.SubscriptionItem; }) => { - const stripeSubs = await getStripeSubs({ - stripeCli: stripeCli, - subIds: cusProduct.subscription_ids || [], + const { db, logger } = req; + const { stripeCli } = attachParams; + + const cusPrice = featureToCusPrice({ + internalFeatureId: newOptions.internal_feature_id!, + cusPrices: cusProduct.customer_prices, + })!; + + const onDecrease = + cusPrice.price.proration_config?.on_decrease || + OnDecrease.ProrateImmediately; + + const stripeProration = onDecreaseToStripeProration[ + onDecrease + ] as Stripe.SubscriptionItemUpdateParams.ProrationBehavior; + + logger.info( + `Handling quantity downgrade for ${newOptions.feature_id}, on decrease: ${onDecrease}, proration: ${stripeProration}`, + ); + + await stripeCli.subscriptionItems.update(subItem.id, { + quantity: newOptions.quantity, + proration_behavior: stripeProration, }); - const prorationBehavior = autumnToStripeProrationBehavior({ - prorationBehavior: config.proration, - }); - - for (const options of optionsToUpdate) { - const { new: newOptions, old: oldOptions } = options; - const subToUpdate = await getUsageBasedSub({ - db, - stripeCli: stripeCli, - subIds: cusProduct.subscription_ids || [], - feature: { - internal_id: newOptions.internal_feature_id, - id: newOptions.feature_id, - } as Feature, - stripeSubs: stripeSubs, + if (!shouldProrate(onDecrease)) { + newOptions.upcoming_quantity = newOptions.quantity; + newOptions.quantity = oldOptions.quantity; + } else { + const cusEnt = getRelatedCusEnt({ + cusPrice, + cusEnts: cusProduct.customer_entitlements, }); - if (!subToUpdate) { - throw new RecaseError({ - message: `Failed to update quantity for ${newOptions.feature_id} to ${newOptions.quantity}`, - code: ErrCode.InternalError, - statusCode: 500, - }); - } - - const curPrices = cusProductToPrices({ cusProduct }); - const price = findPriceForFeature({ - prices: curPrices, - internalFeatureId: newOptions.internal_feature_id, - }); - - if (!price) { - throw new RecaseError({ - message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`, - code: ErrCode.PriceNotFound, - }); - } - - let subItem = findStripeItemForPrice({ - price, - stripeItems: subToUpdate.items.data, - }); - - if (!subItem) { - subItem = await stripeCli.subscriptionItems.create({ - subscription: subToUpdate.id, - price: price.config.stripe_price_id as string, - quantity: newOptions.quantity, - proration_behavior: prorationBehavior, - payment_behavior: "error_if_incomplete", - }); - - logger.info( - `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, - ); - } else { - await stripeCli.subscriptionItems.update(subItem.id, { - quantity: newOptions.quantity, - proration_behavior: prorationBehavior, - payment_behavior: "error_if_incomplete", - }); - logger.info( - `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, - ); - } - - // Update cus ent - const config = price.config as UsagePriceConfig; - let difference = new Decimal(newOptions.quantity) - .minus(new Decimal(oldOptions.quantity)) - .mul(config.billing_units || 1); - - let cusEnt = cusProduct.customer_entitlements.find( - (cusEnt: FullCustomerEntitlement) => - cusEnt.entitlement.internal_feature_id == - newOptions.internal_feature_id, - ); - if (cusEnt) { - await CusEntService.increment({ + const config = cusPrice.price.config as UsagePriceConfig; + const billingUnits = config.billing_units || 1; + let decrementBy = new Decimal(oldOptions.quantity) + .minus(new Decimal(newOptions.quantity)) + .mul(billingUnits) + .toNumber(); + + await CusEntService.decrement({ db, id: cusEnt.id, - amount: difference.toNumber(), + amount: decrementBy, }); } } - - await CusProductService.update({ - db, - cusProductId: cusProduct.id, - updates: { options: optionsToUpdate.map((o) => o.new) }, - }); +}; + +export const handleUpdateFeatureQuantity = async ({ + req, + attachParams, + cusProduct, + stripeSubs, + oldOptions, + newOptions, +}: { + req: any; + attachParams: AttachParams; + cusProduct: FullCusProduct; + stripeSubs: Stripe.Subscription[]; + oldOptions: FeatureOptions; + newOptions: FeatureOptions; +}) => { + const { db, logger } = req; + const { stripeCli } = attachParams; + + const prorationBehavior = "always_invoice"; + + const subToUpdate = await getUsageBasedSub({ + db, + stripeCli: stripeCli, + subIds: cusProduct.subscription_ids || [], + feature: { + internal_id: newOptions.internal_feature_id, + id: newOptions.feature_id, + } as Feature, + stripeSubs: stripeSubs, + }); + + const cusPrice = featureToCusPrice({ + internalFeatureId: newOptions.internal_feature_id!, + cusPrices: cusProduct.customer_prices, + })!; + + const price = cusPrice.price; + + if (!subToUpdate) { + throw new RecaseError({ + message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`, + code: ErrCode.InternalError, + statusCode: 500, + }); + } + + let subItem = findStripeItemForPrice({ + price: price!, + stripeItems: subToUpdate.items.data, + }) as Stripe.SubscriptionItem; + + // const config = price.config as UsagePriceConfig; + // let difference = new Decimal(newOptions.quantity) + // .minus(new Decimal(oldOptions.quantity)) + // .mul(config.billing_units || 1); + + // let cusEnt = getRelatedCusEnt({ + // cusPrice, + // cusEnts: cusProduct.customer_entitlements, + // }); + + // if (cusEnt) { + // await CusEntService.increment({ + // db, + // id: cusEnt.id, + // amount: difference.toNumber(), + // }); + // } + + if (newOptions.quantity < oldOptions.quantity) { + return await handleQuantityDowngrade({ + req, + attachParams, + cusProduct, + stripeSubs, + oldOptions, + newOptions, + subItem, + }); + } else { + return await handleQuantityUpgrade({ + req, + attachParams, + cusProduct, + stripeSubs, + oldOptions, + newOptions, + cusPrice, + stripeSub: subToUpdate, + subItem, + }); + } + + // if (!price) { + // throw new RecaseError({ + // message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`, + // code: ErrCode.PriceNotFound, + // }); + // } + + // if (!subItem) { + // subItem = await stripeCli.subscriptionItems.create({ + // subscription: subToUpdate.id, + // price: price.config.stripe_price_id as string, + // quantity: newOptions.quantity, + // proration_behavior: prorationBehavior, + // payment_behavior: "error_if_incomplete", + // }); + + // logger.info( + // `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, + // ); + // } else { + // await stripeCli.subscriptionItems.update(subItem.id, { + // quantity: newOptions.quantity, + // proration_behavior: prorationBehavior, + // payment_behavior: "error_if_incomplete", + // }); + // logger.info( + // `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, + // ); + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index 72698c8a1..4f8a77484 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -4,8 +4,10 @@ import { AttachResultSchema, } from "../../../cusProducts/AttachParams.js"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; -import { updateFeatureQuantity } from "./updateFeatureQuantity.js"; +import { handleUpdateFeatureQuantity } from "./updateFeatureQuantity.js"; import { AttachConfig } from "@autumn/shared"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; export const handleUpdateQuantityFunction = async ({ req, @@ -24,14 +26,28 @@ export const handleUpdateQuantityFunction = async ({ const { curSameProduct } = attachParamToCusProducts({ attachParams }); // Check balance of each option to update...? + const stripeCli = attachParams.stripeCli; + const cusProduct = curSameProduct!; + const stripeSubs = await getStripeSubs({ + stripeCli: stripeCli, + subIds: cusProduct.subscription_ids || [], + }); - await updateFeatureQuantity({ + for (const options of optionsToUpdate) { + await handleUpdateFeatureQuantity({ + req, + attachParams, + cusProduct, + stripeSubs, + oldOptions: options.old, + newOptions: options.new, + }); + } + + await CusProductService.update({ db: req.db, - stripeCli: attachParams.stripeCli, - cusProduct: curSameProduct!, - optionsToUpdate: optionsToUpdate!, - config, - logger: req.logtail, + cusProductId: cusProduct.id, + updates: { options: optionsToUpdate.map((o) => o.new) }, }); res.status(200).json( diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index c8088a570..59a02a6d2 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -194,6 +194,12 @@ const getSameProductBranch = async ({ return AttachBranch.UpdatePrepaidQuantity; } + if (fromPreview) { + if (hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })) { + return AttachBranch.UpdatePrepaidQuantity; + } + } + // 2. If add on product if (product.is_add_on) { return AttachBranch.AddOn; @@ -215,12 +221,6 @@ const getSameProductBranch = async ({ return AttachBranch.Renew; } - if (fromPreview) { - if (hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })) { - return AttachBranch.UpdatePrepaidQuantity; - } - } - // Invalid, can't attach same product throw new RecaseError({ message: `Product ${product.name} is already attached, can't attach again`, diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 576c21706..87cfa2b7b 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -69,6 +69,7 @@ export const getContUseNewItems = async ({ amount: undefined, description, usage_model: priceToUsageModel(price), + feature_id: ent.feature_id, } as PreviewLineItem; } else { let overage = new Decimal(usage).sub(ent.allowance!).toNumber(); @@ -100,6 +101,7 @@ export const getContUseNewItems = async ({ description, amount, usage_model: priceToUsageModel(price), + feature_id: ent.feature_id, } as PreviewLineItem; } }; diff --git a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts index a84202d51..a7309e42f 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts @@ -6,10 +6,15 @@ import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; import { AttachFunction } from "@autumn/shared"; import { getAttachFunction } from "../attachUtils/getAttachFunction.js"; import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; -import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js"; +import { + attachParamsToProduct, + attachParamToCusProducts, +} from "../attachUtils/convertAttachParams.js"; import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js"; import { getNewProductPreview } from "./getNewProductPreview.js"; import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js"; +import { getUpdateQuantityPreview } from "./getUpdateQuantityPreview.js"; +import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js"; export const attachParamsToPreview = async ({ req, @@ -61,8 +66,8 @@ export const attachParamsToPreview = async ({ preview = await getNewProductPreview({ branch, attachParams, - now, logger, + config, }); } @@ -76,8 +81,8 @@ export const attachParamsToPreview = async ({ if ( func == AttachFunction.UpgradeDiffInterval || - func == AttachFunction.UpdatePrepaidQuantity || - func == AttachFunction.UpgradeSameInterval + func == AttachFunction.UpgradeSameInterval || + func == AttachFunction.UpdatePrepaidQuantity ) { preview = await getUpgradeProductPreview({ req, @@ -87,6 +92,15 @@ export const attachParamsToPreview = async ({ }); } + // if (func == AttachFunction.UpdatePrepaidQuantity) { + // preview = await getUpdateQuantityPreview({ + // req, + // attachParams, + // branch, + // now, + // }); + // } + const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({ attachParams, }); diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index 37e2911bc..b64e5801a 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -1,4 +1,4 @@ -import { AttachBranch, BillingInterval } from "@autumn/shared"; +import { AttachBranch, AttachConfig, BillingInterval } from "@autumn/shared"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; @@ -6,22 +6,25 @@ import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; import { mapToProductItems } from "@/internal/products/productV2Utils.js"; import { addBillingIntervalUnix, + getAlignedIntervalUnix, getNextStartOfMonthUnix, } from "@/internal/products/prices/billingIntervalUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js"; +import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; export const getNewProductPreview = async ({ branch, attachParams, - now, logger, + config, }: { branch: AttachBranch; attachParams: AttachParams; - now: number; logger: any; + config: AttachConfig; }) => { const { org } = attachParams; const newProduct = attachParamsToProduct({ attachParams }); @@ -31,11 +34,21 @@ export const getNewProductPreview = async ({ anchorToUnix = getNextStartOfMonthUnix(BillingInterval.Month); } + const { mergeCusProduct, mergeSubs } = await getMergeCusProduct({ + attachParams, + products: [newProduct], + config, + }); + + if (mergeSubs.length > 0) { + anchorToUnix = mergeSubs[0].current_period_end * 1000; + } + const freeTrial = attachParams.freeTrial; const items = await getItemsForNewProduct({ newProduct, attachParams, - now, + now: attachParams.now, anchorToUnix, freeTrial, logger, @@ -43,11 +56,11 @@ export const getNewProductPreview = async ({ let dueNextCycle = null; - if (freeTrial) { + if (freeTrial || notNullish(anchorToUnix)) { let nextCycleItems = await getItemsForNewProduct({ newProduct, attachParams, - now, + now: attachParams.now, logger, }); @@ -55,9 +68,15 @@ export const getNewProductPreview = async ({ let dueAt = freeTrial ? freeTrialToStripeTimestamp({ freeTrial, - now, + now: attachParams.now, })! * 1000 - : addBillingIntervalUnix(now, minInterval); + : anchorToUnix + ? getAlignedIntervalUnix({ + alignWithUnix: anchorToUnix, + interval: minInterval, + now: attachParams.now, + }) + : addBillingIntervalUnix(attachParams.now || Date.now(), minInterval); dueNextCycle = { line_items: nextCycleItems, @@ -77,6 +96,7 @@ export const getNewProductPreview = async ({ }), features: attachParams.features, anchorToUnix, + now: attachParams.now || Date.now(), }); // Next cycle at @@ -90,7 +110,10 @@ export const getNewProductPreview = async ({ ); return price?.config.interval == minInterval; }), - due_at: addBillingIntervalUnix(now, minInterval), + due_at: addBillingIntervalUnix( + attachParams.now || Date.now(), + minInterval, + ), }; } } diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts new file mode 100644 index 000000000..f8f295bf9 --- /dev/null +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts @@ -0,0 +1,220 @@ +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { getOptions } from "@/internal/api/entitled/checkUtils.js"; +import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; +import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; +import { + addBillingIntervalUnix, + getAlignedIntervalUnix, +} from "@/internal/products/prices/billingIntervalUtils.js"; +import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { + AttachBranch, + BillingInterval, + FreeTrial, + PreviewLineItem, + Price, + UsageModel, +} from "@autumn/shared"; + +import Stripe from "stripe"; +import { + attachParamToCusProducts, + attachParamsToProduct, +} from "../attachUtils/convertAttachParams.js"; +import { intervalsAreSame } from "../attachUtils/getAttachConfig.js"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { Decimal } from "decimal.js"; + +const getNextCycleAt = ({ + prices, + stripeSubs, + willCycleReset, + interval, + now, + freeTrial, +}: { + prices: Price[]; + stripeSubs: Stripe.Subscription[]; + willCycleReset: boolean; + interval: BillingInterval; + now?: number; + freeTrial?: FreeTrial | null; +}) => { + now = now || Date.now(); + + if (freeTrial) { + return { + next_cycle_at: + freeTrialToStripeTimestamp({ + freeTrial, + now, + })! * 1000, + }; + } + + if (willCycleReset) { + const minInterval = getLastInterval({ prices }); + return { + next_cycle_at: addBillingIntervalUnix(now, minInterval), + }; + } + + const minInterval = getLastInterval({ prices }); + const nextCycleAt = getAlignedIntervalUnix({ + alignWithUnix: stripeSubs[0].current_period_end * 1000, + interval: minInterval, + alwaysReturn: true, + }); + + return { + next_cycle_at: nextCycleAt, + }; +}; + +export const getUpdateQuantityPreview = async ({ + req, + attachParams, + branch, + now, +}: { + req: ExtendedRequest; + attachParams: AttachParams; + branch: AttachBranch; + now: number; +}) => { + const { logtail: logger } = req; + + const { stripeCli } = attachParams; + + const { curMainProduct, curSameProduct } = attachParamToCusProducts({ + attachParams, + }); + const curCusProduct = curMainProduct!; + + const stripeSubs = await getStripeSubs({ + stripeCli, + subIds: curCusProduct?.subscription_ids || [], + expand: ["items.data.price.tiers"], + }); + + const curPreviewItems = await getItemsForCurProduct({ + stripeSubs, + attachParams, + now, + logger, + }); + + // Get prorated amounts for new product + const newProduct = attachParamsToProduct({ attachParams }); + const intervalsSame = intervalsAreSame({ attachParams }); + const anchorToUnix = + intervalsSame && stripeSubs.length > 0 + ? stripeSubs[0].current_period_end * 1000 + : undefined; + + const newPreviewItems = await getItemsForNewProduct({ + newProduct, + attachParams, + now, + anchorToUnix, + freeTrial: attachParams.freeTrial, + stripeSubs, + logger, + }); + + const lastInterval = getLastInterval({ prices: newProduct.prices }); + + let dueNextCycle = undefined; + if (!isFreeProduct(newProduct.prices)) { + const nextCycleAt = getNextCycleAt({ + prices: newProduct.prices, + stripeSubs, + willCycleReset: !intervalsSame, + interval: lastInterval, + now, + freeTrial: attachParams.freeTrial, + }); + + let nextCycleItems = await getItemsForNewProduct({ + newProduct, + attachParams, + interval: attachParams.freeTrial ? undefined : lastInterval, + logger, + }); + + dueNextCycle = { + line_items: nextCycleItems, + due_at: nextCycleAt.next_cycle_at, + }; + } + + let items = [...curPreviewItems, ...newPreviewItems]; + + for (const item of structuredClone(curPreviewItems)) { + let priceId = item.price_id; + let newItem = newPreviewItems.find((i) => i.price_id == priceId); + + if (!newItem) { + continue; + } + + let newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2); + let curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2); + + if (newItemAmount.add(curItemAmount).eq(0)) { + items = items.filter((i) => i.price_id !== priceId); + } + } + + const dueTodayAmt = items + .reduce((acc, item) => acc.plus(item.amount ?? 0), new Decimal(0)) + .toDecimalPlaces(2) + .toNumber(); + + let options = getOptions({ + prodItems: mapToProductItems({ + prices: newProduct.prices, + entitlements: newProduct.entitlements, + features: attachParams.features, + }), + features: attachParams.features, + anchorToUnix, + now, + freeTrial: attachParams.freeTrial, + cusProduct: curSameProduct, + }); + + items = items.filter((item) => item.amount !== 0); + + if (branch == AttachBranch.UpdatePrepaidQuantity) { + items = items.filter((item) => item.usage_model == UsageModel.Prepaid); + dueNextCycle!.line_items = dueNextCycle!.line_items.filter( + (item) => item.usage_model == UsageModel.Prepaid, + ); + } + + let dueToday: + | { + line_items: PreviewLineItem[]; + total: number; + } + | undefined = { + line_items: items, + total: dueTodayAmt, + }; + + if (branch == AttachBranch.SameCustomEnts) { + dueToday = undefined; + } + + return { + currency: attachParams.org.default_currency, + due_today: dueToday, + due_next_cycle: dueNextCycle, + options, + }; +}; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 628c0b9c0..a43a98cd0 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -90,8 +90,11 @@ export const getUpgradeProductPreview = async ({ const { stripeCli } = attachParams; - const { curMainProduct } = attachParamToCusProducts({ attachParams }); - const curCusProduct = curMainProduct!; + const { curMainProduct, curSameProduct } = attachParamToCusProducts({ + attachParams, + }); + + const curCusProduct = curSameProduct || curMainProduct!; const stripeSubs = await getStripeSubs({ stripeCli, @@ -183,6 +186,7 @@ export const getUpgradeProductPreview = async ({ anchorToUnix, now, freeTrial: attachParams.freeTrial, + cusProduct: curCusProduct, }); items = items.filter((item) => item.amount !== 0); @@ -208,11 +212,12 @@ export const getUpgradeProductPreview = async ({ dueToday = undefined; } + console.log("Due today: ", dueToday); + return { currency: attachParams.org.default_currency, due_today: dueToday, due_next_cycle: dueNextCycle, - options, }; }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index 07e8513fd..2856a045f 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -303,7 +303,7 @@ export const getResetBalance = ({ }: { entitlement: Entitlement; options: FeatureOptions | undefined | null; - relatedPrice: Price | undefined | null; + relatedPrice?: Price | null; productQuantity?: number; }) => { // 1. No related price @@ -321,9 +321,6 @@ export const getResetBalance = ({ let quantity = options?.quantity; let billingUnits = (relatedPrice.config as UsagePriceConfig).billing_units; if (nullish(quantity) || nullish(billingUnits)) { - // console.log("WARNING: Quantity or billing units not found"); - // console.log("Entitlement:", entitlement.id, entitlement.feature_id); - // console.log("Options:", options); return entitlement.allowance; } diff --git a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts index faa1c5f56..3c35ace96 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts @@ -60,4 +60,12 @@ export class CusPriceService { return data as FullCustomerPrice[]; } + + static async delete({ db, id }: { db: DrizzleCli; id: string }) { + const deleted = await db + .delete(customerPrices) + .where(eq(customerPrices.id, id)) + .returning(); + return deleted; + } } diff --git a/server/src/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.ts b/server/src/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.ts new file mode 100644 index 000000000..06e4b80b1 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.ts @@ -0,0 +1,14 @@ +import { FullCustomerPrice, UsagePriceConfig } from "@autumn/shared"; + +export const featureToCusPrice = ({ + internalFeatureId, + cusPrices, +}: { + internalFeatureId: string; + cusPrices: FullCustomerPrice[]; +}) => { + return cusPrices.find((cusPrice) => { + const config = cusPrice.price.config as UsagePriceConfig; + return config.internal_feature_id === internalFeatureId; + }); +}; diff --git a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts index 7401a98bf..1169c1690 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts @@ -4,6 +4,7 @@ import { FullCustomerEntitlement, FullCustomerPrice, getFeatureInvoiceDescription, + OnDecrease, Price, UsagePriceConfig, } from "@autumn/shared"; diff --git a/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts b/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductResponse.ts similarity index 95% rename from server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts rename to server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductResponse.ts index 17254159a..75ff61b9c 100644 --- a/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductRepsonse.ts +++ b/server/src/internal/customers/cusUtils/cusProductResponseUtils/getCusProductResponse.ts @@ -27,6 +27,16 @@ import { getProductResponse, } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; +const getQuantityData = ({ cusProduct }: { cusProduct: FullCusProduct }) => { + return { + prepaid_quantities: cusProduct.options.map((o) => { + return { + quantity: o.quantity, + feature_id: o.feature_id, + }; + }), + }; +}; export const getCusProductResponse = async ({ cusProduct, subs, @@ -154,6 +164,7 @@ export const getCusProductResponse = async ({ product: fullProduct, features, withDisplay: false, + options: cusProduct.options, }); return CusProductResponseSchema.parse({ diff --git a/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts b/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts index 1c698c6cb..901b0bcb4 100644 --- a/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts +++ b/server/src/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.ts @@ -4,7 +4,7 @@ import { Feature, Organization, } from "@autumn/shared"; -import { getCusProductResponse } from "./getCusProductRepsonse.js"; +import { getCusProductResponse } from "./getCusProductResponse.js"; export const processFullCusProducts = async ({ fullCusProducts, diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 2216559b7..ea0b6bae3 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -343,6 +343,7 @@ cusRouter.get( isActive: cusProduct.status === CusProductStatus.Active, isCustom: cusProduct.is_custom, isCanceled: cusProduct.canceled_at !== null, + cusProductId: cusProduct.id, } : productV2, features, diff --git a/server/src/internal/invoices/invoiceUtils/createAndFinalizeInvoice.ts b/server/src/internal/invoices/invoiceUtils/createAndFinalizeInvoice.ts index b9146228c..6065ae56f 100644 --- a/server/src/internal/invoices/invoiceUtils/createAndFinalizeInvoice.ts +++ b/server/src/internal/invoices/invoiceUtils/createAndFinalizeInvoice.ts @@ -1,15 +1,24 @@ +import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; import Stripe from "stripe"; export const createAndFinalizeInvoice = async ({ stripeCli, + paymentMethod, stripeCusId, stripeSubId, invoiceItems, + errorOnPaymentFail = true, + voidIfFailed = true, + logger, }: { stripeCli: Stripe; + paymentMethod: Stripe.PaymentMethod | null; stripeCusId: string; stripeSubId: string; invoiceItems?: Stripe.InvoiceItemCreateParams[]; + errorOnPaymentFail?: boolean; + voidIfFailed?: boolean; + logger?: any; }) => { const invoice = await stripeCli.invoices.create({ customer: stripeCusId, @@ -31,5 +40,24 @@ export const createAndFinalizeInvoice = async ({ auto_advance: false, }); + if (finalInvoice.status == "open") { + const { + paid, + error, + invoice: paidInvoice, + } = await payForInvoice({ + stripeCli, + invoiceId: finalInvoice.id, + paymentMethod, + logger, + errorOnFail: errorOnPaymentFail, + voidIfFailed, + }); + + if (paid) { + finalInvoice = paidInvoice!; + } + } + return { invoice: finalInvoice }; }; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index e36fd8262..05e919666 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -1,12 +1,18 @@ import Stripe from "stripe"; import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; -import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { + cusProductToEnts, + cusProductToPrices, +} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getSubItemAmount } from "@/external/stripe/stripeSubUtils/getSubItemAmount.js"; import { Decimal } from "decimal.js"; import { calculateProrationAmount } from "../prorationUtils.js"; -import { getBillingType } from "@/internal/products/prices/priceUtils.js"; +import { + getBillingType, + getPriceEntitlement, +} from "@/internal/products/prices/priceUtils.js"; import { BillingType, PreviewLineItem } from "@autumn/shared"; import { priceToInvoiceDescription } from "../invoiceFormatUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; @@ -64,6 +70,8 @@ export const getItemsForCurProduct = async ({ const periodEnd = sub.current_period_end * 1000; + const ents = cusProductToEnts({ cusProduct: curCusProduct }); + const ent = getPriceEntitlement(price, ents); if (now < periodEnd) { const finalProration = getProration({ now, @@ -97,6 +105,7 @@ export const getItemsForCurProduct = async ({ amount: proratedAmount, usage_model: priceToUsageModel(price), price_id: price.id!, + feature_id: ent?.feature.id, }); } } diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 74976b1d9..e2f9ea5e3 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -8,6 +8,7 @@ import { FreeTrial, PreviewLineItem, BillingType, + AttachConfig, } from "@autumn/shared"; import { AttachParams } from "../../customers/cusProducts/AttachParams.js"; import { @@ -38,6 +39,7 @@ import { attachParamToCusProducts, } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; +import { getMergeCusProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.js"; export const getDefaultPriceStr = ({ org, @@ -127,7 +129,6 @@ export const getItemsForNewProduct = async ({ logger: any; }) => { const { org, features } = attachParams; - now = now || Date.now(); const items: PreviewLineItem[] = []; @@ -177,6 +178,7 @@ export const getItemsForNewProduct = async ({ description, amount, usage_model: priceToUsageModel(price), + feature_id: ent?.feature_id, }); continue; } @@ -191,6 +193,7 @@ export const getItemsForNewProduct = async ({ }), usage_model: priceToUsageModel(price), price_id: price.id, + feature_id: ent?.feature_id, }); continue; } diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index e67cc3826..65b4e5156 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -195,6 +195,10 @@ export const getPriceEntitlement = ( ) => { let config = price.config as UsagePriceConfig; + if (config.type == PriceType.Fixed) { + return null; + } + const entitlement = entitlements.find((ent) => { let entIdMatch = notNullish(price.entitlement_id) && price.entitlement_id == ent.id; diff --git a/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts b/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts index c3abb313d..1b5382f10 100644 --- a/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts +++ b/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts @@ -1,8 +1,11 @@ import { BillingType, + Feature, FixedPriceConfig, Infinite, Price, + ProductItem, + UsageModel, UsagePriceConfig, } from "@autumn/shared"; import { isFixedPrice } from "./usagePriceUtils/classifyUsagePrice.js"; @@ -13,6 +16,8 @@ import { calculateProrationAmount, Proration, } from "@/internal/invoices/prorationUtils.js"; +import { itemToPriceAndEnt } from "../../product-items/productItemUtils/itemToPriceAndEnt.js"; +import { isPriceItem } from "../../product-items/productItemUtils/getItemType.js"; export const getAmountForQuantity = ({ price, @@ -64,16 +69,59 @@ export const getAmountForQuantity = ({ return amount.toDecimalPlaces(10).toNumber(); }; +export const itemToInvoiceAmount = ({ + item, + quantity, + overage, +}: { + item: ProductItem; + quantity?: number; + overage?: number; +}) => { + let amount = 0; + if (isPriceItem(item)) { + amount = item.price!; + } + + if (!nullish(quantity) && !nullish(overage)) { + throw new Error( + `itemToInvoiceAmount: quantity or overage is required, autumn item: ${item.feature_id}`, + ); + } + + let price = { + config: { + usage_tiers: item.tiers || [ + { + to: Infinite, + amount: item.price!, + }, + ], + billing_units: item.billing_units || 1, + }, + } as unknown as Price; + + if (item.usage_model == UsageModel.Prepaid) { + amount = getAmountForQuantity({ price, quantity: quantity! }); + } else { + amount = getAmountForQuantity({ price, quantity: overage! }); + } + + return amount; +}; + export const priceToInvoiceAmount = ({ price, - overage, + item, quantity, + overage, proration, now, }: { - price: Price; + price?: Price; + item?: ProductItem; + quantity?: number; // quantity should be multiplied by billing units overage?: number; - quantity?: number; proration?: Proration; now?: number; }) => { @@ -81,23 +129,27 @@ export const priceToInvoiceAmount = ({ let amount = 0; - if (isFixedPrice({ price })) { - amount = (price.config as FixedPriceConfig).amount; - } else { - const config = price.config as UsagePriceConfig; - let billingType = getBillingType(config); - - if (!nullish(quantity) && !nullish(overage)) { - throw new Error( - `getAmountForPrice: quantity or overage is required, autumn price: ${price.id}`, - ); - } - - if (billingType == BillingType.UsageInAdvance) { - amount = getAmountForQuantity({ price, quantity: quantity! }); + if (price) { + if (isFixedPrice({ price })) { + amount = (price.config as FixedPriceConfig).amount; } else { - amount = getAmountForQuantity({ price, quantity: overage! }); + const config = price.config as UsagePriceConfig; + let billingType = getBillingType(config); + + if (!nullish(quantity) && !nullish(overage)) { + throw new Error( + `getAmountForPrice: quantity or overage is required, autumn price: ${price.id}`, + ); + } + + if (billingType == BillingType.UsageInAdvance) { + amount = getAmountForQuantity({ price, quantity: quantity! }); + } else { + amount = getAmountForQuantity({ price, quantity: overage! }); + } } + } else { + amount = itemToInvoiceAmount({ item: item!, quantity, overage }); } if (proration) { diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index c1f9965b8..bc0a5131e 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -10,6 +10,8 @@ import { Feature, FeatureType, AppEnv, + OnIncrease, + UsageModel, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { notNullish, nullish } from "@/utils/genUtils.js"; @@ -147,6 +149,17 @@ const validateProductItem = ({ }); } } + + if ( + item.usage_model == UsageModel.Prepaid && + item.config?.on_increase == OnIncrease.BillImmediately + ) { + throw new RecaseError({ + message: `Bill immediately is not supported for prepaid just yet, contact us at hey@useautumn.com if you're interested!`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } }; export const validateProductItems = ({ newItems, diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index f398dd770..5f410d0b9 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -10,6 +10,8 @@ import { AttachScenario, ProductPropertiesSchema, BillingInterval, + FeatureOptions, + UsageModel, } from "@autumn/shared"; import { sortProductItems } from "../../pricecn/pricecnUtils.js"; import { @@ -31,11 +33,13 @@ export const getProductItemResponse = ({ features, currency, withDisplay = true, + options, }: { item: ProductItem; features: Feature[]; currency?: string; withDisplay?: boolean; + options?: FeatureOptions[]; }) => { // 1. Get item type let type = getItemType(item); @@ -49,11 +53,26 @@ export const getProductItemResponse = ({ let priceData = itemToPriceOrTiers({ item }); + let quantity = undefined; + let upcomingQuantity = undefined; + if (item.usage_model == UsageModel.Prepaid && notNullish(options)) { + let option = options!.find((o) => o.feature_id == item.feature_id); + quantity = option?.quantity + ? option?.quantity * (item.billing_units ?? 1) + : undefined; + + upcomingQuantity = option?.upcoming_quantity + ? option?.upcoming_quantity * (item.billing_units ?? 1) + : undefined; + } + return ProductItemResponseSchema.parse({ type, ...item, display: withDisplay ? display : undefined, ...priceData, + quantity, + next_cycle_quantity: upcomingQuantity, }); }; @@ -120,6 +139,7 @@ export const getProductResponse = async ({ currency, db, withDisplay = true, + options, }: { product: FullProduct; features: Feature[]; @@ -127,6 +147,7 @@ export const getProductResponse = async ({ currency?: string; db?: DrizzleCli; withDisplay?: boolean; + options?: FeatureOptions[]; }) => { // 1. Get items with display let items = mapToProductItems({ @@ -139,6 +160,7 @@ export const getProductResponse = async ({ features, currency, withDisplay, + options, }); }); diff --git a/server/src/internal/products/productV2Utils.ts b/server/src/internal/products/productV2Utils.ts index f42974120..81c31c94c 100644 --- a/server/src/internal/products/productV2Utils.ts +++ b/server/src/internal/products/productV2Utils.ts @@ -103,6 +103,7 @@ export const mapToProductV2 = ({ created_at: product.created_at, items: items, + stripe_id: product.processor?.id || null, }; return productV2; diff --git a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts b/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts index e07accdac..e27e01fc8 100644 --- a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts +++ b/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts @@ -145,21 +145,13 @@ export const createUpgradeProrationInvoice = async ({ if (shouldBillNow(onIncrease)) { const { invoice: finalInvoice } = await createAndFinalizeInvoice({ stripeCli, + paymentMethod, stripeCusId: sub.customer as string, stripeSubId: sub.id, }); - const { invoice: paidInvoice, error } = await payForInvoice({ - stripeCli, - paymentMethod, - invoiceId: finalInvoice.id, - logger, - errorOnFail: true, - voidIfFailed: true, - }); - - logger.info(`Paid for invoice ${paidInvoice?.id}`); - return paidInvoice; + logger.info(`Paid for invoice ${finalInvoice?.id}`); + return finalInvoice; } return null; diff --git a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts index 2859e70c2..0a01335cc 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts @@ -107,6 +107,7 @@ export const createDowngradeProrationInvoice = async ({ if (shouldBillNow(onDecrease)) { const { invoice: finalInvoice } = await createAndFinalizeInvoice({ stripeCli, + paymentMethod: null, stripeCusId: sub.customer as string, stripeSubId: sub.id, }); diff --git a/server/src/utils/importUtils/parseCsv.ts b/server/src/utils/importUtils/parseCsv.ts index ac96161fa..fd98c0578 100644 --- a/server/src/utils/importUtils/parseCsv.ts +++ b/server/src/utils/importUtils/parseCsv.ts @@ -12,8 +12,14 @@ export interface ImportCustomer { options?: FeatureOptions[]; // business_id;name;email;active_pass_count;Stripe id;Base price;free_trial_end } -export const parseCsv = (slug: string): Promise => { - const path = `scripts/customers/${slug}/data2.csv`; +export const parseCsv = ({ + slug, + filename, +}: { + slug: string; + filename?: string; +}): Promise => { + const path = `scripts/customers/${slug}/${filename || "data.csv"}`; const results: ImportCustomer[] = []; return new Promise((resolve, reject) => { diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 99ffb3610..2c44eaf2c 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -43,12 +43,17 @@ export const constructPrepaidItem = ({ billingUnits = 100, includedUsage = 0, isOneOff = false, + config = { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, }: { featureId: string; price?: number; billingUnits?: number; includedUsage?: number; isOneOff?: boolean; + config?: ProductItemConfig; }) => { let item: ProductItem = { feature_id: featureId, @@ -59,6 +64,8 @@ export const constructPrepaidItem = ({ interval: isOneOff ? null : ProductItemInterval.Month, included_usage: includedUsage, + + config, }; return item; diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 5c83bdd0e..2282355d6 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -129,11 +129,13 @@ export const constructProduct = ({ let product: ProductV2 = { id: id_, - name: isAnnual - ? `${keyToTitle(type)} (Annual)` - : interval - ? `${keyToTitle(type)} (${interval})` - : keyToTitle(type), + name: id + ? keyToTitle(id) + : isAnnual + ? `${keyToTitle(type)} (Annual)` + : interval + ? `${keyToTitle(type)} (${interval})` + : keyToTitle(type), items, is_add_on: isAddOn, is_default: type == "free" && isDefault, diff --git a/server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts b/server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts new file mode 100644 index 000000000..4bdeb86bf --- /dev/null +++ b/server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts @@ -0,0 +1,197 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; +import { + AppEnv, + CusProduct, + CusProductStatus, + Customer, + customerProducts, + customers, + FullCusProduct, + FullCustomer, + Product, + products, +} from "@autumn/shared"; +import { and, desc, eq, gt, lt, sql } from "drizzle-orm"; + +let cusProductsQuery = ({ + orgId, + env, + inStatuses = ACTIVE_STATUSES, + lastProductId, + pageSize = 250, +}: { + orgId: string; + env: AppEnv; + inStatuses?: CusProductStatus[]; + lastProductId?: string; + pageSize?: number; +}) => { + const withStatusFilter = () => { + return inStatuses + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + }; + + return sql` + SELECT + cp.*, + row_to_json(prod) AS product, + + -- Spread customer_prices fields + add price field + COALESCE( + json_agg(DISTINCT ( + to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*)) + )) FILTER (WHERE cpr.id IS NOT NULL), + '[]'::json + ) AS customer_prices, + + -- Spread customer_entitlements fields + add entitlement and replaceables + COALESCE( + json_agg(DISTINCT ( + to_jsonb(ce.*) || jsonb_build_object( + 'entitlement', ( + SELECT row_to_json(ent_with_feature) + FROM ( + SELECT e.*, row_to_json(f) AS feature + FROM entitlements e + JOIN features f ON e.internal_feature_id = f.internal_id + WHERE e.id = ce.entitlement_id + ) AS ent_with_feature + ), + 'replaceables', ( + SELECT COALESCE( + json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL), + '[]'::json + ) + FROM replaceables r + WHERE r.cus_ent_id = ce.id + ) + ) + )) FILTER (WHERE ce.id IS NOT NULL), + '[]'::json + ) AS customer_entitlements, + + -- free_trial + ( + SELECT row_to_json(ft) + FROM free_trials ft + WHERE ft.id = cp.free_trial_id + ) AS free_trial + + FROM customer_products cp + JOIN products prod ON cp.internal_product_id = prod.internal_id + LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id + LEFT JOIN prices p ON cpr.price_id = p.id + LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id + WHERE prod.org_id = ${orgId} AND prod.env = ${env} + ${withStatusFilter()} + ${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``} + GROUP BY cp.id, prod.* + ORDER BY cp.id DESC + LIMIT ${pageSize} + `; +}; +export const getAllFullCusProducts = async ({ + db, + orgId, + env, + inStatuses = ACTIVE_STATUSES, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + inStatuses?: CusProductStatus[]; +}) => { + let lastProductId = ""; + let allData: any[] = []; + let pageSize = 500; + + while (true) { + const data = await db.execute( + cusProductsQuery({ + orgId, + env, + inStatuses, + lastProductId, + pageSize, + }), + ); + + if (data.length === 0) break; + + console.log(`Fetched ${data.length} customer products`); + allData.push(...data); + lastProductId = data[data.length - 1].id as string; + } + + return allData as FullCusProduct[]; +}; + +export const getAllCustomers = async ({ + db, + orgId, + env, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; +}) => { + let lastCustomerId = ""; + let allData: any[] = []; + let pageSize = 500; + + while (true) { + const data = await db.query.customers.findMany({ + where: and( + eq(customers.org_id, orgId), + eq(customers.env, env), + lastCustomerId ? lt(customers.internal_id, lastCustomerId) : undefined, + ), + orderBy: [desc(customers.internal_id)], + limit: pageSize, + }); + + if (data.length === 0) break; + + console.log(`Fetched ${data.length} customers`); + allData.push(...data); + lastCustomerId = data[data.length - 1].internal_id as string; + } + + return allData as Customer[]; +}; + +export const getAllFullCustomers = async ({ + db, + orgId, + env, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; +}) => { + let [customers, fullCusProducts] = await Promise.all([ + getAllCustomers({ db, orgId, env }), + getAllFullCusProducts({ db, orgId, env }), + ]); + + let cusProdMap: Record = {}; + for (const cp of fullCusProducts) { + let internalCusId = cp.internal_customer_id; + if (!cusProdMap[internalCusId]) { + cusProdMap[internalCusId] = []; + } + cusProdMap[internalCusId].push(cp); + } + + return customers.map((customer) => { + return { + ...customer, + customer_products: cusProdMap[customer.internal_id] || [], + }; + }) as FullCustomer[]; +}; diff --git a/server/tests/attach/prepaid/prepaid1.ts b/server/tests/attach/prepaid/prepaid1.ts new file mode 100644 index 000000000..29030a64a --- /dev/null +++ b/server/tests/attach/prepaid/prepaid1.ts @@ -0,0 +1,198 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + Customer, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addHours, addMonths } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { expect } from "chai"; +import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; + +const testCase = "prepaid1"; + +export let pro = constructProduct({ + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], + excludeBase: true, + type: "pro", +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proration downgrade, single use`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + let customer: Customer; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + customer = res.customer; + testClockId = res.testClockId!; + }); + + const options = [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + ]; + + it("should attach pro product to customer", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options, + }); + + let customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + }); + }); + + it("should reduce quantity to 200 and have correct sub item quantity + cus product quantity", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + }); + }); + + it("should increase quantity to 400 and have correct sub item quantity + invoice..", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + waitForInvoice: 5000, + }); + }); + + const newQuantity = 200; + it("should decrease quantity to 200, advance clock to next cycle and have correct balance", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: newQuantity, + }, + ], + }); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + const autumnCus = await autumn.customers.get(customerId); + expect(autumnCus.features[TestFeature.Messages].balance).to.equal( + newQuantity, + ); + + expect(autumnCus.invoices.length).to.equal(3); + expect(autumnCus.invoices[0].total).to.equal((newQuantity / 100) * 12.5); + + const cusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + }); + // console.log(cusProduct); + expect(cusProduct?.options[0].quantity).to.equal(newQuantity / 100); + expect(cusProduct?.options[0].upcoming_quantity).to.not.exist; + }); +}); diff --git a/server/tests/attach/prepaid/prepaid2.ts b/server/tests/attach/prepaid/prepaid2.ts new file mode 100644 index 000000000..d76ad375a --- /dev/null +++ b/server/tests/attach/prepaid/prepaid2.ts @@ -0,0 +1,149 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addWeeks } from "date-fns"; + +const testCase = "prepaid2"; + +export let pro = constructProduct({ + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], + excludeBase: true, + type: "pro", +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: upgrade quantity, prorate immediately, single use`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + testClockId = res.testClockId!; + }); + + const options = [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + ]; + + it("should attach pro product to customer", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options, + }); + + let customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + }); + }); + + it("should increase advance test clock, increase quantity to 400 and have correct sub item quantity + invoice..", async function () { + const usage = Math.floor(Math.random() * 220); + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage, + }); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 10, + }); + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + usage: [ + { + featureId: TestFeature.Messages, + value: usage, + }, + ], + waitForInvoice: 5000, + }); + }); +}); diff --git a/server/tests/attach/prepaid/prepaid3.ts b/server/tests/attach/prepaid/prepaid3.ts new file mode 100644 index 000000000..d40fef563 --- /dev/null +++ b/server/tests/attach/prepaid/prepaid3.ts @@ -0,0 +1,158 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addHours, addMonths, addWeeks } from "date-fns"; +import { expect } from "chai"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; + +const testCase = "prepaid3"; + +export let pro = constructProduct({ + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.None, + }, + }), + ], + excludeBase: true, + type: "pro", +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: upgrade quantity, prorate next cycle, single use`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + testClockId = res.testClockId!; + }); + + const options = [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + ]; + + it("should attach pro product to customer", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options, + }); + + let customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + }); + }); + + it("should increase advance test clock, increase quantity to 400", async function () { + const usage = Math.floor(Math.random() * 220); + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage, + }); + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + usage: [ + { + featureId: TestFeature.Messages, + value: usage, + }, + ], + }); + + const customer = await autumn.customers.get(customerId); + expect(customer.invoices.length).to.equal(1); + }); + + it("should advance test clock to end of cycle and have correct invoice", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 10, + }); + }); +}); diff --git a/server/tests/attach/prepaid/prepaid4.ts b/server/tests/attach/prepaid/prepaid4.ts new file mode 100644 index 000000000..cfc6a8e89 --- /dev/null +++ b/server/tests/attach/prepaid/prepaid4.ts @@ -0,0 +1,148 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { timeout } from "@/utils/genUtils.js"; +import { expect } from "chai"; +import { addHours, addMonths } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; + +const testCase = "prepaid4"; + +export let pro = constructProduct({ + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], + excludeBase: true, + type: "pro", +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: Testing prepaid reset`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + testClockId = res.testClockId!; + }); + + const options = [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + ]; + + it("should attach pro product to customer", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options, + }); + + let customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + }); + }); + // return; + + const usage = 100; + it("should track usage for prepaid and have correct balance", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage, + }); + + await timeout(3000); + + const customer = await autumn.customers.get(customerId); + const newBalance = options[0].quantity - usage; + expect(customer.features[TestFeature.Messages].balance).to.equal( + newBalance, + ); + }); + + it("should advance clock to next cycle and have correct balance", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 25, + }); + + const customer = await autumn.customers.get(customerId); + expect(customer.features[TestFeature.Messages].balance).to.equal( + options[0].quantity, + ); + }); +}); diff --git a/server/tests/attach/prepaid/prepaid5.ts b/server/tests/attach/prepaid/prepaid5.ts new file mode 100644 index 000000000..79c7dc13f --- /dev/null +++ b/server/tests/attach/prepaid/prepaid5.ts @@ -0,0 +1,266 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + Customer, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { addHours, addMonths, addWeeks } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { expect } from "chai"; +import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; + +const testCase = "prepaid5"; + +export let prepaidAddOn = constructProduct({ + type: "pro", + excludeBase: true, + id: "topup", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], + isAddOn: true, +}); + +export let pro = constructProduct({ + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 250, + }), + ], +}); +export let premium = constructProduct({ + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, + }), + ], +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: prepaid add on, with entities`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + let customer: Customer; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + withTestClock: false, + }); + + addPrefixToProducts({ + products: [pro, premium, prepaidAddOn], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, premium, prepaidAddOn], + db, + orgId: org.id, + env, + }); + + customer = res.customer; + // testClockId = res.testClockId!; + }); + + const entity1Id = "1"; + const entity2Id = "2"; + const entities = [ + { + id: entity1Id, + name: "entity1", + feature_id: TestFeature.Users, + }, + { + id: entity2Id, + name: "entity2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro product to entity1", async function () { + await autumn.entities.create(customerId, entities); + + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity1Id, + product: pro, + stripeCli, + db, + org, + env, + }); + + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity1Id, + product: prepaidAddOn, + otherProducts: [pro], + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 100, + }, + ], + numSubs: 2, + }); + }); + + const oldEntity2Quantity = 300; + it("should advance test clock and attach top up to entity2", async function () { + // await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addWeeks(new Date(), 2).getTime(), + // waitForSeconds: 10, + // }); + + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity2Id, + product: premium, + stripeCli, + db, + org, + env, + numSubs: 3, + }); + + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity2Id, + product: prepaidAddOn, + otherProducts: [premium], + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: oldEntity2Quantity, + }, + ], + numSubs: 4, + }); + }); + + it("should increase prepaid add on quantity for entity1", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity1Id, + product: prepaidAddOn, + otherProducts: [pro], + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + numSubs: 4, + waitForInvoice: 10000, + }); + }); + + const newEntity2Quantity = 200; + it("should decrease prepaid add on quantity for entity2", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + entityId: entity2Id, + product: prepaidAddOn, + otherProducts: [premium], + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: newEntity2Quantity, + }, + ], + numSubs: 4, + waitForInvoice: 5000, + }); + + const entity2 = await autumn.entities.get(customerId, entity2Id); + expect(entity2.invoices.length).to.equal(2); + let creditProd = entity2.products.find((p: any) => p.id == prepaidAddOn.id); + expect(creditProd).to.exist; + const messagesItem = creditProd!.items.find( + (i: any) => i.feature_id == TestFeature.Messages, + ); + + expect(messagesItem).to.exist; + expect(messagesItem.quantity).to.equal(oldEntity2Quantity); + expect(messagesItem.next_cycle_quantity).to.equal(newEntity2Quantity); + }); + + return; +}); diff --git a/server/tests/attach/prepaid/prepaid6.ts b/server/tests/attach/prepaid/prepaid6.ts new file mode 100644 index 000000000..4b3433354 --- /dev/null +++ b/server/tests/attach/prepaid/prepaid6.ts @@ -0,0 +1,186 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + Customer, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expect } from "chai"; + +const testCase = "prepaid6"; + +export let pro = constructProduct({ + type: "pro", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], +}); +export let premium = constructProduct({ + type: "premium", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 12.5, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], +}); + +describe(`${chalk.yellowBright(`attach/${testCase}: prepaid add on, with entities`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + let customer: Customer; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + withTestClock: false, + }); + + addPrefixToProducts({ + products: [pro, premium], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, premium], + db, + orgId: org.id, + env, + }); + + customer = res.customer; + // testClockId = res.testClockId!; + }); + + it("should attach pro product", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + ], + }); + }); + + return; + + // it("should advance test clock and attach premium", async function () { + // await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addWeeks(new Date(), 2).getTime(), + // waitForSeconds: 10, + // }); + + // await attachAndExpectCorrect({ + // autumn, + // customerId, + // entityId: entity2Id, + // product: premium, + // stripeCli, + // db, + // org, + // env, + // numSubs: 3, + // }); + + // await attachAndExpectCorrect({ + // autumn, + // customerId, + // entityId: entity2Id, + // product: prepaidAddOn, + // otherProducts: [premium], + // stripeCli, + // db, + // org, + // env, + // options: [ + // { + // feature_id: TestFeature.Messages, + // quantity: oldEntity2Quantity, + // }, + // ], + // numSubs: 4, + // }); + // }); + + // it("should increase prepaid add on quantity for entity1", async function () { + // await attachAndExpectCorrect({ + // autumn, + // customerId, + // entityId: entity1Id, + // product: prepaidAddOn, + // otherProducts: [pro], + // stripeCli, + // db, + // org, + // env, + // options: [ + // { + // feature_id: TestFeature.Messages, + // quantity: 200, + // }, + // ], + // numSubs: 4, + // waitForInvoice: 10000, + // }); + // }); + + return; +}); diff --git a/server/tests/utils/expectUtils/expectAttach.ts b/server/tests/utils/expectUtils/expectAttach.ts index 9d374fd00..62fa4b2cc 100644 --- a/server/tests/utils/expectUtils/expectAttach.ts +++ b/server/tests/utils/expectUtils/expectAttach.ts @@ -25,6 +25,7 @@ export const attachAndExpectCorrect = async ({ customerId, entityId, product, + otherProducts, options, stripeCli, db, @@ -40,6 +41,7 @@ export const attachAndExpectCorrect = async ({ customerId: string; entityId?: string; product: ProductV2; + otherProducts?: ProductV2[]; options?: FeatureOptions[]; stripeCli: Stripe; db: DrizzleCli; @@ -60,9 +62,10 @@ export const attachAndExpectCorrect = async ({ entity_id: entityId, }); + const optionsCopy = structuredClone(options); const total = getAttachTotal({ preview, - options, + options: optionsCopy, }); const { checkout_url } = await autumn.attach({ @@ -89,7 +92,7 @@ export const attachAndExpectCorrect = async ({ } const productCount = customer.products.reduce((acc: number, p: any) => { - if (product.group == p.group) { + if (product.group == p.group && !p.is_add_on) { return acc + 1; } else return acc; }, 0); @@ -110,18 +113,23 @@ export const attachAndExpectCorrect = async ({ ).filter(notNullish); const multiInterval = intervals.length > 1; - expectInvoicesCorrect({ - customer, - first: multiInterval ? undefined : { productId: product.id, total }, - second: multiInterval ? { productId: product.id, total } : undefined, - }); + const skipInvoiceCheck = + preview.branch == AttachBranch.UpdatePrepaidQuantity && total == 0; + if (!skipInvoiceCheck) { + expectInvoicesCorrect({ + customer, + first: multiInterval ? undefined : { productId: product.id, total }, + second: multiInterval ? { productId: product.id, total } : undefined, + }); + } if (!skipFeatureCheck) { expectFeaturesCorrect({ customer, product, usage, - options, + options: optionsCopy, + otherProducts, }); } diff --git a/server/tests/utils/expectUtils/expectFeaturesCorrect.ts b/server/tests/utils/expectUtils/expectFeaturesCorrect.ts index aad4714a6..486a94fce 100644 --- a/server/tests/utils/expectUtils/expectFeaturesCorrect.ts +++ b/server/tests/utils/expectUtils/expectFeaturesCorrect.ts @@ -13,12 +13,14 @@ import { Customer, Entity } from "autumn-js"; export const expectFeaturesCorrect = ({ customer, product, + otherProducts, options, usage, entities, }: { customer: Customer | Entity; product: ProductV2; + otherProducts?: ProductV2[]; options?: FeatureOptions[]; usage?: { featureId: string; @@ -32,6 +34,8 @@ export const expectFeaturesCorrect = ({ new Set(product.items.map((i) => i.feature_id)), ).filter(notNullish); + const otherItems = otherProducts?.flatMap((p) => p.items) || []; + for (const featureId of featureIds) { let includedUsage: string | number = 0; @@ -40,7 +44,7 @@ export const expectFeaturesCorrect = ({ if (item.included_usage === undefined) continue; - for (const item of items) { + for (const item of [...items, ...otherItems]) { if (item.feature_id !== featureId) continue; if (item.included_usage == Infinite) { includedUsage = Infinite; diff --git a/server/tests/utils/expectUtils/expectSubUtils.ts b/server/tests/utils/expectUtils/expectSubUtils.ts index 2eb40b894..c1f18e187 100644 --- a/server/tests/utils/expectUtils/expectSubUtils.ts +++ b/server/tests/utils/expectUtils/expectSubUtils.ts @@ -6,11 +6,13 @@ import { } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { CusService } from "@/internal/customers/CusService.js"; +import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js"; import { nullish } from "@/utils/genUtils.js"; import { AppEnv, + BillingType, CusProductStatus, FullCusProduct, Organization, @@ -211,6 +213,25 @@ export const expectSubItemsCorrect = async ({ `sub item for price: ${(price.config as any).internal_feature_id || price.config.interval} should exist`, ).to.exist; } + + // 2. If prepaid... + let billingType = getBillingType(price.config); + if (billingType == BillingType.UsageInAdvance) { + const featureId = (price.config as any).feature_id; + const options = cusProduct.options.find((o) => o.feature_id == featureId); + + expect( + options, + `options should exist for prepaid price (featureId: ${featureId})`, + ).to.exist; + + const expectedQuantity = options?.upcoming_quantity || options?.quantity; + expect( + subItem?.quantity, + `sub item quantity for prepaid price (featureId: ${featureId}) should be ${expectedQuantity}`, + ).to.equal(expectedQuantity); + continue; + } } expect( diff --git a/server/tests/utils/testAttachUtils/testAttachUtils.ts b/server/tests/utils/testAttachUtils/testAttachUtils.ts index 2c94978c8..f9778ae98 100644 --- a/server/tests/utils/testAttachUtils/testAttachUtils.ts +++ b/server/tests/utils/testAttachUtils/testAttachUtils.ts @@ -1,4 +1,10 @@ -import { AttachPreview } from "@autumn/shared"; +import { notNullish } from "@/utils/genUtils.js"; +import { + AttachBranch, + AttachPreview, + OnIncrease, + UsageModel, +} from "@autumn/shared"; import { Decimal } from "decimal.js"; // 1. Calculate total @@ -10,29 +16,80 @@ export const getAttachTotal = ({ options?: any; }) => { const dueToday = preview?.due_today; + let dueTodayTotal = dueToday?.line_items.reduce((acc: any, item: any) => { + // Skip prepaid items that are already in the options + if ( + item.usage_model == UsageModel.Prepaid && + options.some((o: any) => o.feature_id == item.feature_id) + ) { + return acc; + } + if (item.amount) { return acc.plus(item.amount); } return acc; }, new Decimal(0)) || new Decimal(0); + const isUpdatePrepaidQuantity = + preview?.branch == AttachBranch.UpdatePrepaidQuantity; + if (isUpdatePrepaidQuantity) { + dueTodayTotal = new Decimal(0); + } + for (const option of options || []) { - let previewOption = preview?.options.find( + const previewOption = preview?.options.find( (o: any) => o.feature_id === option.feature_id || o.feature_id === option.featureId, ); - if (!previewOption) { + const currentQuantity = previewOption.current_quantity || 0; + const newQuantity = option.quantity || 0; + let difference = newQuantity - currentQuantity; + difference = difference / previewOption.billing_units; + + const isDecrease = newQuantity < currentQuantity; + const isIncrease = newQuantity > currentQuantity; + + if (isDecrease && previewOption.config.on_decrease == "none") { + option.quantity = currentQuantity; continue; } - const prepaidAmt = new Decimal(previewOption.price) - .times(option.quantity) - .dividedBy(previewOption.billing_units); + if ( + isUpdatePrepaidQuantity && + isIncrease && + previewOption.config.on_increase == OnIncrease.ProrateNextCycle + ) { + continue; + } - dueTodayTotal = dueTodayTotal.plus(prepaidAmt); + const differenceAmount = new Decimal(previewOption.price).times(difference); + dueTodayTotal = dueTodayTotal.plus(differenceAmount); + + // Prorated difference + if (previewOption.proration_amount) { + dueTodayTotal = dueTodayTotal.plus( + new Decimal(previewOption.proration_amount), + ); + } + + // let previewOption = preview?.options.find( + // (o: any) => + // o.feature_id === option.feature_id || o.feature_id === option.featureId, + // ); + + // if (!previewOption) { + // continue; + // } + + // const prepaidAmt = new Decimal(previewOption.price) + // .times(option.quantity) + // .dividedBy(previewOption.billing_units); + + // dueTodayTotal = dueTodayTotal.plus(prepaidAmt); } return dueTodayTotal.toDecimalPlaces(2).toNumber(); diff --git a/shared/models/attachModels/attachPreviewModels.ts b/shared/models/attachModels/attachPreviewModels.ts index 33dcc6924..b04c318e4 100644 --- a/shared/models/attachModels/attachPreviewModels.ts +++ b/shared/models/attachModels/attachPreviewModels.ts @@ -11,6 +11,7 @@ export interface PreviewLineItem { price: string; price_id: string; usage_model?: UsageModel; + feature_id?: string; } export interface AttachPreview { diff --git a/shared/models/cusModels/cusResModels/cusProductResponse.ts b/shared/models/cusModels/cusResModels/cusProductResponse.ts index 67b4d9c21..842e697e2 100644 --- a/shared/models/cusModels/cusResModels/cusProductResponse.ts +++ b/shared/models/cusModels/cusResModels/cusProductResponse.ts @@ -19,4 +19,12 @@ export const CusProductResponseSchema = z.object({ current_period_end: z.number().nullish(), entity_id: z.string().nullish(), items: z.array(ProductItemResponseSchema).nullish(), + prepaid_quantities: z + .array( + z.object({ + quantity: z.number(), + feature_id: z.string(), + }), + ) + .nullish(), }); diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index 027469c22..69b32775f 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -20,6 +20,7 @@ import { CusProductStatus } from "./cusProductEnums.js"; export const FeatureOptionsSchema = z.object({ feature_id: z.string(), quantity: z.number(), // same as prepaid + upcoming_quantity: z.number().nullish(), adjustable_quantity: z.boolean().nullish(), internal_feature_id: z.string().nullish(), diff --git a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts b/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts index 4a9be52b2..8de865685 100644 --- a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts +++ b/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts @@ -23,6 +23,8 @@ export const ProductItemResponseSchema = z.object({ usage_model: z.nativeEnum(UsageModel).nullish(), billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) reset_usage_when_enabled: z.boolean().nullish(), + quantity: z.number().nullish(), + next_cycle_quantity: z.number().nullish(), display: z .object({ diff --git a/shared/models/productV2Models/productV2Models.ts b/shared/models/productV2Models/productV2Models.ts index 6a5e84e54..31cda6885 100644 --- a/shared/models/productV2Models/productV2Models.ts +++ b/shared/models/productV2Models/productV2Models.ts @@ -15,6 +15,7 @@ export const ProductV2Schema = z.object({ free_trial: FreeTrialSchema.nullish(), items: z.array(ProductItemSchema), created_at: z.number(), + stripe_id: z.string().nullish(), }); export type ProductV2 = z.infer; diff --git a/shared/package.json b/shared/package.json index e0a66aed4..4e6b5310c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -20,6 +20,7 @@ "db:migrate": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit migrate --config drizzle.config.ts" }, "dependencies": { + "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", diff --git a/shared/utils/displayUtils.ts b/shared/utils/displayUtils.ts index becafe4d2..7a59e53f0 100644 --- a/shared/utils/displayUtils.ts +++ b/shared/utils/displayUtils.ts @@ -1,6 +1,6 @@ import { Feature } from "../models/featureModels/featureModels.js"; import { Decimal } from "decimal.js"; - +import { format } from "date-fns"; export const getFeatureName = ({ feature, plural, @@ -92,12 +92,14 @@ export const getFeatureInvoiceDescription = ({ billingUnits = 1, prodName, isPrepaid = false, + fromUnix, }: { feature: Feature; usage: number; billingUnits?: number | null; prodName?: string; isPrepaid?: boolean; + fromUnix?: number; }) => { const { singular, plural } = getSingularAndPlural({ feature }); @@ -119,5 +121,9 @@ export const getFeatureInvoiceDescription = ({ result = `${prodName} - ${result}`; } + if (fromUnix) { + result = `${result} (from ${format(fromUnix, "d MMM yyyy")})`; + } + return result; }; diff --git a/vite/src/components/general/AdminHover.tsx b/vite/src/components/general/AdminHover.tsx index ece4606d1..6dfb551d5 100644 --- a/vite/src/components/general/AdminHover.tsx +++ b/vite/src/components/general/AdminHover.tsx @@ -39,7 +39,7 @@ export const AdminHover = forwardRef< {isAdmin && ( diff --git a/vite/src/utils/product/productItemUtils.ts b/vite/src/utils/product/productItemUtils.ts index d6a21b17e..938a8abde 100644 --- a/vite/src/utils/product/productItemUtils.ts +++ b/vite/src/utils/product/productItemUtils.ts @@ -83,6 +83,8 @@ export const shouldShowProrationConfig = ({ // If pay per use single use const usageType = itemToUsageType({ item, features }); + if (item.usage_model == UsageModel.Prepaid) return true; + // if ( // usageType == ProductItemFeatureType.SingleUse && // item.usage_model == UsageModel.Prepaid diff --git a/vite/src/views/customers/customer/product/ProductOptions.tsx b/vite/src/views/customers/customer/product/ProductOptions.tsx index c13d24ea6..85b2aa612 100644 --- a/vite/src/views/customers/customer/product/ProductOptions.tsx +++ b/vite/src/views/customers/customer/product/ProductOptions.tsx @@ -1,3 +1,5 @@ +import { notNullish } from "@/utils/genUtils"; + export const ProductOptions = ({ options, setOptions, @@ -23,6 +25,11 @@ export const ProductOptions = ({ {option.quantity !== undefined && option.quantity !== null && (

{option.quantity}

+ {notNullish(option.upcoming_quantity) && ( +

+ (Upcoming: {option.upcoming_quantity}) +

+ )}
)} diff --git a/vite/src/views/customers/customer/product/components/AttachPreviewDetails.tsx b/vite/src/views/customers/customer/product/components/AttachPreviewDetails.tsx index 45a0e0e4c..52217de43 100644 --- a/vite/src/views/customers/customer/product/components/AttachPreviewDetails.tsx +++ b/vite/src/views/customers/customer/product/components/AttachPreviewDetails.tsx @@ -3,6 +3,9 @@ import React from "react"; import { AttachNewItems } from "./attach-preview/AttachNewItems"; import { DueToday } from "./attach-preview/DueToday"; import { DueNextCycle } from "./attach-preview/DueNextCycle"; +import { UpdateQuantity } from "./attach-preview/UpdateQuantity"; +import { AttachBranch } from "@autumn/shared"; +import { OptionsInput } from "./attach-preview/OptionsInput"; export const AttachPreviewDetails = () => { const { org, attachState } = useProductContext(); @@ -12,10 +15,21 @@ export const AttachPreviewDetails = () => { return null; } + const branch = preview.branch; + const isUpdatePrepaidQuantity = branch == AttachBranch.UpdatePrepaidQuantity; + return ( - - + {/* Options */} + + {/* */} + + {!isUpdatePrepaidQuantity && ( + <> + + + + )} ); diff --git a/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx b/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx index c775fbc23..7bfd745d6 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx @@ -26,8 +26,9 @@ export const DueNextCycle = () => { if (!preview.due_next_cycle) return null; if ( - (!preview.due_next_cycle.line_items?.length && !preview.options?.length) || - preview.options.every((option: any) => option.full_price == option.price) + !preview.due_next_cycle.line_items?.length && + !preview.options?.length + // || preview.options.every((option: any) => option.full_price == option.price) ) return null; diff --git a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx index 1074bd098..3730529bb 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx @@ -67,7 +67,7 @@ export const DueToday = () => { ); })} - + {/* */} {options.length > 0 && options.map((option: any, index: number) => { const { feature_name, billing_units, quantity, price } = option; @@ -76,18 +76,6 @@ export const DueToday = () => { {product.name} - {feature_name} - {/* ) => { - const newOptions = [...options]; - newOptions[index].quantity = - parseInt(e.target.value) * billing_units; - setOptions(newOptions); - }} - > - - */}
{ + const { attachState, product, org } = useProductContext(); + const { preview, options, setOptions } = attachState; + + if (!options || options.length == 0) return null; + + return ( +
+ {options.length > 0 && + options.map((option: any, index: number) => { + const { feature_name, billing_units, quantity, price } = option; + return ( + + + {product.name} - {feature_name} + +
+ ) => { + const newOptions = [...options]; + newOptions[index].quantity = + parseInt(e.target.value) * billing_units; + + setOptions(newOptions); + }} + className="w-12 h-7" + /> + + {/* + ×{" "} + {formatAmount({ + defaultCurrency: currency, + amount: price, + maxFractionDigits: 2, + })}{" "} + per {billing_units === 1 ? " " : billing_units} {feature_name} + */} +
+
+ ); + })} +
+ ); +}; diff --git a/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx b/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx new file mode 100644 index 000000000..e5d147c2e --- /dev/null +++ b/vite/src/views/customers/customer/product/components/attach-preview/UpdateQuantity.tsx @@ -0,0 +1,85 @@ +import { PriceItem } from "@/components/pricing/attach-pricing-dialog"; +import { Input } from "@/components/ui/input"; +import { notNullish } from "@/utils/genUtils"; +import { formatAmount } from "@/utils/product/productItemUtils"; +import { useProductContext } from "@/views/products/product/ProductContext"; +import { AttachBranch } from "@autumn/shared"; + +export const UpdateQuantity = () => { + const { attachState, product, org } = useProductContext(); + const { preview, options, setOptions } = attachState; + + if (preview.branch !== AttachBranch.UpdatePrepaidQuantity) { + return null; + } + + const currency = org?.default_currency || "USD"; + + const getTotalPrice = () => { + return options.reduce((acc: number, option: any) => { + const currentQuantity = option.current_quantity || 0; + const newQuantity = option.quantity || 0; + let difference = newQuantity - currentQuantity; + difference = difference / option.billing_units; + + const isDecrease = newQuantity < currentQuantity; + + if (isDecrease && option.config.on_decrease == "none") { + return acc; + } + + return acc + option.price * difference; + }, 0); + }; + + return ( +
+

Update prepaid quantity

+ {options.length > 0 && + options.map((option: any, index: number) => { + const { feature_name, billing_units, quantity, price } = option; + return ( + + + {product.name} - {feature_name} + +
+ ) => { + const newOptions = [...options]; + newOptions[index].quantity = + parseInt(e.target.value) * billing_units; + + setOptions(newOptions); + }} + className="w-12 h-7" + /> + + + ×{" "} + {formatAmount({ + defaultCurrency: currency, + amount: price, + maxFractionDigits: 2, + })}{" "} + per {billing_units === 1 ? " " : billing_units} {feature_name} + +
+
+ ); + })} + + Total: + + {formatAmount({ + amount: getTotalPrice(), + defaultCurrency: currency, + maxFractionDigits: 2, + })} + + +
+ ); +}; diff --git a/vite/src/views/customers/customer/product/hooks/useAttachState.tsx b/vite/src/views/customers/customer/product/hooks/useAttachState.tsx index 74322bd8b..52790ca6e 100644 --- a/vite/src/views/customers/customer/product/hooks/useAttachState.tsx +++ b/vite/src/views/customers/customer/product/hooks/useAttachState.tsx @@ -109,19 +109,9 @@ export const useAttachState = ({ free_trial: initialProductRef.current?.free_trial || null, }); - console.log( - "Initial product ref", - JSON.stringify(initialProductRef.current, null, 2), - ); - console.log("Sorted product", JSON.stringify(sortedProduct, null, 2)); - setItemsChanged(hasItemsChanged); }, [product]); - useEffect(() => { - console.log("Initial product ref", initialProductRef.current); - }, []); - const getButtonDisabled = () => { if (product?.isActive && !itemsChanged && !flags.isCanceled) { if (flags.hasPrepaid) { @@ -161,8 +151,6 @@ export const useAttachState = ({ }; const getButtonText = () => { - console.log("Is prepaid:", flags.hasPrepaid); - console.log("Items changed:", itemsChanged); if (product?.isActive && !itemsChanged) { if (flags.isOneOff) { return "Attach Product"; diff --git a/vite/src/views/products/product/ManageProduct.tsx b/vite/src/views/products/product/ManageProduct.tsx index df1a98e74..05a3bbabc 100644 --- a/vite/src/views/products/product/ManageProduct.tsx +++ b/vite/src/views/products/product/ManageProduct.tsx @@ -18,7 +18,23 @@ export const ManageProduct = ({
- +

{product.name}

diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index 1c32e0a4e..4c88deeeb 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -2,22 +2,12 @@ import { Feature, FeatureType, FeatureUsageType, - Infinite, ProductItemFeatureType, ProductItemInterval, TierInfinite, } from "@autumn/shared"; import { useEffect, useState } from "react"; import { useProductContext } from "@/views/products/product/ProductContext"; -import { Button } from "@/components/ui/button"; -import { PlusIcon } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useProductItemContext } from "./ProductItemContext"; @@ -27,7 +17,6 @@ import { } from "@/utils/product/productItemUtils"; import { ConfigWithFeature } from "./components/ConfigWithFeature"; import FixedPriceConfig from "./components/ConfigFixedPrice"; -import { getFeature } from "@/utils/product/entitlementUtils"; import { isFeaturePriceItem, isPriceItem } from "@/utils/product/getItemType"; export const ProductItemConfig = () => { diff --git a/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnDecreaseSelect.tsx b/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnDecreaseSelect.tsx index 68893a3ad..7149566ca 100644 --- a/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnDecreaseSelect.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnDecreaseSelect.tsx @@ -1,13 +1,23 @@ import { OnDecrease, UsageModel } from "@autumn/shared"; import { useProductItemContext } from "../../../ProductItemContext"; import { ProrationSelect } from "./ProrationSelect"; -import { nullish } from "zod/v4"; +import { nullish } from "@/utils/genUtils"; -const optionToText = (option: OnDecrease) => { +const optionToText = ({ + option, + usageModel, +}: { + option: OnDecrease; + usageModel: UsageModel; +}) => { switch (option) { case OnDecrease.Prorate: return "Prorate"; case OnDecrease.None: + if (usageModel == UsageModel.Prepaid) { + return "No proration (balance will be kept till next cycle)"; + } + return "No proration (usage will be kept till next cycle)"; } }; @@ -31,18 +41,6 @@ export const OnDecreaseSelect = () => { return OnDecrease.None; }; - // useEffect(() => { - // if (!item.config?.on_decrease) { - // setItem({ - // ...item, - // config: { - // ...item.config, - // on_decrease: OnDecrease.Prorate, - // }, - // }); - // } - // }, [item]); - const text = item.usage_model == UsageModel.PayPerUse ? "On usage decrease" @@ -60,7 +58,9 @@ export const OnDecreaseSelect = () => { config: { ...item.config, on_decrease: value }, }); }} - optionToText={optionToText} + optionToText={(option: OnDecrease) => + optionToText({ option, usageModel: item.usage_model }) + } options={[OnDecrease.Prorate, OnDecrease.None]} />
diff --git a/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnIncreaseSelect.tsx b/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnIncreaseSelect.tsx index e3526e1f1..7840c489f 100644 --- a/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnIncreaseSelect.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/advanced-config/proration-config/OnIncreaseSelect.tsx @@ -34,7 +34,14 @@ export const OnIncreaseSelect = () => { setItem({ ...item, config: { ...item.config, on_increase: value } }) } optionToText={optionToText} - options={Object.values(OnIncrease)} + options={Object.values(OnIncrease).filter((o) => { + if ( + item.usage_model == UsageModel.Prepaid && + o == OnIncrease.BillImmediately + ) + return false; + return true; + })} />
);