From efeaace7b0a8a4286e40ea047aa3f3c56c24aade Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 3 Jun 2025 08:07:41 +0100 Subject: [PATCH] adding attach preview --- server/src/external/stripe/stripeCusUtils.ts | 15 + .../external/stripe/stripeInvoiceSubUtils.ts | 2 +- server/src/external/stripe/stripeSubUtils.ts | 15 + .../stripe/stripeSubUtils/createStripeSub.ts | 4 +- .../stripe/stripeSubUtils/getSubItemAmount.ts | 2 - .../stripeSubUtils/stripeSubItemUtils.ts | 16 + server/src/external/stripe/stripeWebhooks.ts | 50 +++- .../handleCheckoutCompleted.ts | 1 + .../webhookHandlers/handleSubDeleted.ts | 76 +++-- .../webhookHandlers/handleSubUpdated.ts | 1 + .../stripe/webhookUtils/webhookUtils.ts | 49 +++ server/src/index.ts | 6 +- .../src/internal/api/entitled/checkUtils.ts | 44 ++- .../api/entitled/handlers/getAttachPreview.ts | 2 +- .../add-product/createFullCusProduct.ts | 13 +- .../add-product/handleAddFreeProduct.ts | 3 +- .../customers/add-product/handleAddProduct.ts | 4 +- .../add-product/handleCreateCheckout.ts | 2 +- .../customers/add-product/initCusEnt.ts | 2 +- .../scheduleFlow/handleScheduleFunction.ts | 25 +- .../handleEntsChangedFunction.ts | 1 + .../updateFeatureQuantity.ts | 72 +++-- .../updateQuantityFlow/updateQuantityFlow.ts | 8 +- .../upgradeFlow/handleUpgradeFunction.ts | 21 +- .../upgradeFlow/updateStripeSubs.ts | 22 +- .../internal/customers/attach/attachRouter.ts | 107 +------ .../attach/attachUtils/getAttachBranch.ts | 50 ++-- .../attach/attachUtils/getAttachConfig.ts | 21 +- .../attach/attachUtils/getAttachFunction.ts | 25 +- .../attach/attachUtils/getAttachParams.ts | 33 +- .../attach/attachUtils/handleAttachErrors.ts | 2 +- .../attach/attachUtils/processAttachBody.ts | 12 +- .../internal/customers/attach/handleAttach.ts | 73 +++++ .../handleAttachPreview/attachPreviewUtils.ts | 1 + .../getDowngradeProductPreview.ts | 57 ++++ .../getNewProductPreview.ts | 103 +++---- .../getUpdateEntsPreview.ts | 31 ++ .../getUpgradeProductPreview.ts | 252 +++++++++------- .../handleAttachPreview.ts | 52 +++- .../customers/attach/models/AttachBranch.ts | 35 +-- .../customers/attach/models/AttachFlags.ts | 2 +- .../change-product/billRemainingUsages.ts | 37 +-- .../change-product/handleChangeProduct.ts | 161 +++++----- .../change-product/handleDowngrade.ts | 283 ------------------ .../customers/change-product/handleUpgrade.ts | 7 +- .../customers/cusProducts/AttachParams.ts | 2 + .../cusProducts/CusProductService.ts | 2 +- .../cusProducts/cusPrices/cusPriceUtils.ts | 7 + .../customers/cusProducts/cusProductUtils.ts | 6 + .../handlers/handleCreateCustomer.ts | 1 + .../internal/customers/internalCusRouter.ts | 2 + .../customers/previews/getUpgradePreview.ts | 1 - .../internal/invoices/invoiceFormatUtils.ts | 99 +++--- .../previewItemUtils/getItemsForCurProduct.ts | 117 ++++++++ .../previewItemUtils/getItemsForNewProduct.ts | 240 +++++++++++++++ .../src/internal/invoices/prorationUtils.ts | 12 +- server/src/internal/mainRouter.ts | 4 - server/src/internal/orgs/OrgService.ts | 6 + .../internal/products/compareProductUtils.ts | 59 ++-- .../products/free-trials/freeTrialUtils.ts | 16 +- .../products/internalProductRouter.ts | 11 +- .../products/prices/billingIntervalUtils.ts | 8 +- .../prices/priceUtils/convertPrice.ts | 31 ++ .../prices/priceUtils/findPriceUtils.ts | 33 ++ .../prices/priceUtils/priceIntervalUtils.ts | 20 ++ .../prices/priceUtils/usagePriceUtils.ts | 6 + .../product-items/productItemUtils.ts | 45 ++- .../products/productUtils/classifyProduct.ts | 30 ++ server/src/internal/rewards/referralUtils.ts | 2 + server/src/utils/formatUtils.ts | 18 ++ server/src/utils/genUtils.ts | 6 +- server/src/utils/scriptUtils/constructItem.ts | 19 +- .../utils/scriptUtils/createTestProducts.ts | 16 +- shared/index.ts | 6 + .../attachModels/attachEnums/AttachBranch.ts | 21 ++ .../attachModels/attachEnums/AttachConfig.ts | 15 + .../attachEnums/AttachFunction.ts | 15 + .../attachModels/attachPreviewModels.ts | 7 + .../models/checkModels/checkPreviewModels.ts | 7 + shared/utils/displayUtils.ts | 33 ++ .../pricing/attach-pricing-dialog.tsx | 2 +- vite/src/utils/formatUtils/formatDateUtils.ts | 5 + vite/src/utils/formatUtils/formatTextUtils.ts | 10 +- vite/src/utils/linkUtils.ts | 5 + .../product/product-item/formatProductItem.ts | 32 +- vite/src/utils/product/productItemUtils.ts | 4 +- vite/src/utils/productUtils.ts | 21 ++ .../customer/CustomerProductList.tsx | 38 +-- .../components/CusProductStripeLink.tsx | 67 +++++ .../customer/product/CustomerProductView.tsx | 40 +-- .../product/components/AttachButton.tsx | 17 +- .../product/components/AttachModal.tsx | 130 ++++---- .../components/AttachPreviewDetails.tsx | 94 ++---- .../attach-preview/AdjustQuantity.tsx | 50 ++++ .../components/attach-preview/AttachInfo.tsx | 40 +++ .../attach-preview/AttachNewItems.tsx | 23 ++ .../attach-preview/DueNextCycle.tsx | 69 +++++ .../components/attach-preview/DueToday.tsx | 107 +++++++ .../customer/product/hooks/useAttachState.tsx | 112 +++++-- .../views/products/product/ProductView.tsx | 20 +- 100 files changed, 2382 insertions(+), 1227 deletions(-) create mode 100644 server/src/external/stripe/webhookUtils/webhookUtils.ts create mode 100644 server/src/internal/customers/attach/handleAttach.ts create mode 100644 server/src/internal/customers/attach/handleAttachPreview/attachPreviewUtils.ts create mode 100644 server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts create mode 100644 server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts delete mode 100644 server/src/internal/customers/change-product/handleDowngrade.ts create mode 100644 server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts create mode 100644 server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts create mode 100644 server/src/internal/products/prices/priceUtils/convertPrice.ts create mode 100644 server/src/internal/products/productUtils/classifyProduct.ts create mode 100644 server/src/utils/formatUtils.ts create mode 100644 shared/models/attachModels/attachEnums/AttachBranch.ts create mode 100644 shared/models/attachModels/attachEnums/AttachConfig.ts create mode 100644 shared/models/attachModels/attachEnums/AttachFunction.ts create mode 100644 shared/models/attachModels/attachPreviewModels.ts create mode 100644 vite/src/utils/productUtils.ts create mode 100644 vite/src/views/customers/customer/components/CusProductStripeLink.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/AdjustQuantity.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/AttachInfo.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/AttachNewItems.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/DueNextCycle.tsx create mode 100644 vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index 0416d0e30..1474def0c 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -7,6 +7,21 @@ import { StatusCodes } from "http-status-codes"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +export const getStripeCus = async ({ + stripeCli, + stripeId, +}: { + stripeCli: Stripe; + stripeId: string; +}) => { + try { + const stripeCus = await stripeCli.customers.retrieve(stripeId); + return stripeCus as Stripe.Customer; + } catch (error) { + return undefined; + } +}; + export const createStripeCusIfNotExists = async ({ db, org, diff --git a/server/src/external/stripe/stripeInvoiceSubUtils.ts b/server/src/external/stripe/stripeInvoiceSubUtils.ts index 0b0294770..a1e5b2d42 100644 --- a/server/src/external/stripe/stripeInvoiceSubUtils.ts +++ b/server/src/external/stripe/stripeInvoiceSubUtils.ts @@ -58,7 +58,7 @@ export const createStripeSubThroughInvoice = async ({ // ...paymentMethodData, customer: customer.processor.id, items: subItems as any, - trial_end: freeTrialToStripeTimestamp(freeTrial), + trial_end: freeTrialToStripeTimestamp({ freeTrial }), metadata, add_invoice_items: invoiceItems, collection_method: "send_invoice", diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 17d8ea4e5..53c2c3448 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -248,6 +248,21 @@ export const subIsPrematurelyCanceled = (sub: Stripe.Subscription) => { ); }; +export const autumnToStripeProrationBehavior = ({ + prorationBehavior, +}: { + prorationBehavior: ProrationBehavior; +}) => { + switch (prorationBehavior) { + case ProrationBehavior.Immediately: + return "always_invoice"; + case ProrationBehavior.NextBilling: + return "create_prorations"; + case ProrationBehavior.None: + return "none"; + } +}; + export const getStripeProrationBehavior = ({ org, prorationBehavior, diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index 8ef43743b..ff481e5b6 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -76,7 +76,7 @@ export const createStripeSub = async ({ return await stripeCli.invoices.createPreview({ subscription_details: { items: subItems as any, - trial_end: freeTrialToStripeTimestamp(freeTrial), + trial_end: freeTrialToStripeTimestamp({ freeTrial }), billing_cycle_anchor: billingCycleAnchorUnix ? Math.floor(billingCycleAnchorUnix / 1000) : undefined, @@ -91,7 +91,7 @@ export const createStripeSub = async ({ ...paymentMethodData, customer: customer.processor.id, items: subItems as any, - trial_end: freeTrialToStripeTimestamp(freeTrial), + trial_end: freeTrialToStripeTimestamp({ freeTrial }), payment_behavior: "error_if_incomplete", add_invoice_items: invoiceItems, collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", diff --git a/server/src/external/stripe/stripeSubUtils/getSubItemAmount.ts b/server/src/external/stripe/stripeSubUtils/getSubItemAmount.ts index a4a0ea93f..3b67ca217 100644 --- a/server/src/external/stripe/stripeSubUtils/getSubItemAmount.ts +++ b/server/src/external/stripe/stripeSubUtils/getSubItemAmount.ts @@ -50,8 +50,6 @@ export const getSubItemAmount = ({ quantity, }); - console.log("Tiered amount:", tieredAmount); - return tieredAmount; } diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index 154d816c9..cce1e3b7a 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -8,6 +8,22 @@ import { } from "@autumn/shared"; import Stripe from "stripe"; +export const findSubItemForPrice = ({ + price, + subItems, +}: { + price: Price; + subItems: Stripe.SubscriptionItem[]; +}) => { + return subItems.find((si: Stripe.SubscriptionItem) => { + const config = price.config as UsagePriceConfig; + return ( + config.stripe_price_id == si.price?.id || + config.stripe_product_id == si.price?.product + ); + }); +}; + export const findPriceInStripeItems = ({ prices, subItem, diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 4f38f6141..9a43006aa 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -1,7 +1,7 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; import { AuthType, LoggerAction, Organization } from "@autumn/shared"; import express from "express"; -import stripe from "stripe"; +import stripe, { Stripe } from "stripe"; import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js"; import { handleSubscriptionDeleted } from "./webhookHandlers/handleSubDeleted.js"; @@ -16,9 +16,22 @@ import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubS import { format } from "date-fns"; import { createLogtailWithContext } from "../logtail/logtailUtils.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; export const stripeWebhookRouter = express.Router(); +const logStripeWebhook = ({ + req, + event, +}: { + req: ExtendedRequest; + event: Stripe.Event; +}) => { + console.log( + `${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${req.org.slug} | ${event.id}`, + ); +}; + stripeWebhookRouter.post( "/:orgId/:env", express.raw({ type: "application/json" }), @@ -30,13 +43,23 @@ stripeWebhookRouter.post( const { db } = request; let org: Organization; - try { - org = await OrgService.get({ db: request.db, orgId }); - } catch (error) { + + const data = await OrgService.getWithFeatures({ + db: request.db, + orgId, + env, + }); + + if (!data) { response.status(200).send(`Org ${orgId} not found`); return; } + request.org = data.org; + request.features = data.features; + request.env = env; + org = data.org; + if (!org.stripe_config) { console.log(`Org ${orgId} does not have a stripe config`); response.status(200).send(`Org ${orgId} does not have a stripe config`); @@ -51,8 +74,6 @@ stripeWebhookRouter.post( return; } - // event = JSON.parse(request.body); - try { request.body = JSON.parse(request.body); request.authType = AuthType.Stripe; @@ -60,6 +81,8 @@ stripeWebhookRouter.post( console.log("Error parsing body", error); } + logStripeWebhook({ req: request, event }); + const logger = createLogtailWithContext({ action: LoggerAction.StripeWebhook, event_type: event.type, @@ -69,13 +92,13 @@ stripeWebhookRouter.post( env, }); - console.log( - `${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${chalk.yellow( - "Stripe Webhook: ", - )} ${request.url} ${request.url.includes("live") ? " " : ""}| ${ - event?.type - } | ID: ${event?.id}`, - ); + // console.log( + // `${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${chalk.yellow( + // "Stripe Webhook: ", + // )} ${request.url} ${request.url.includes("live") ? " " : ""}| ${ + // event?.type + // } | ID: ${event?.id}`, + // ); try { switch (event.type) { @@ -117,6 +140,7 @@ stripeWebhookRouter.post( case "checkout.session.completed": const checkoutSession = event.data.object; await handleCheckoutSessionCompleted({ + req: request, db, checkoutSession, org, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index c02671bed..5c95d72ea 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -316,6 +316,7 @@ export const handleCheckoutSessionCompleted = async ({ : undefined : undefined, scenario: AttachScenario.New, + logger, }); } diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts index d44af7a4f..f22420f4e 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts @@ -27,46 +27,43 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han import { DrizzleCli } from "@/db/initDrizzle.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; +import { getCusPaymentMethod } from "../stripeCusUtils.js"; +import { createStripeCli } from "../utils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { webhookToAttachParams } from "../webhookUtils/webhookUtils.js"; const handleCusProductDeleted = async ({ req, db, + stripeCli, cusProduct, subscription, logger, - env, - org, prematurelyCanceled, }: { req: ExtendedRequest; db: DrizzleCli; + stripeCli: Stripe; cusProduct: FullCusProduct; subscription: Stripe.Subscription; logger: any; - env: AppEnv; - org: Organization; prematurelyCanceled: boolean; }) => { - if ( - !cusProduct || - cusProduct.customer!.env !== env || - cusProduct.customer!.org_id !== org.id - ) { - console.log( - " ⚠️ customer product not found / env mismatch / org mismatch", - ); - return; - } + const { org, env } = req; + const paymentMethod = await getCusPaymentMethod({ + stripeCli, + stripeId: cusProduct.customer!.processor?.id, + }); + + const customer = cusProduct.customer!; if (cusProduct.internal_entity_id) { - let customer = cusProduct.customer; let usagePrices = cusProduct.customer_prices.filter( (cp: FullCustomerPrice) => getBillingType(cp.price.config!) === BillingType.UsageInArrear, ); if (usagePrices.length > 0) { - // Create invoice for remaining usage charges logger.info( `Customer ${customer!.name} (${customer!.id}), Entity: ${cusProduct.internal_entity_id}`, ); @@ -77,20 +74,12 @@ const handleCusProductDeleted = async ({ db, curCusProduct: cusProduct, logger, - attachParams: { - customer: cusProduct.customer!, - org, - invoiceOnly: false, - - // PLACEHOLDERS - products: [], - prices: [], - entitlements: [], - features: [], - freeTrial: null, - optionsList: [], - entities: [], - }, + attachParams: webhookToAttachParams({ + req, + stripeCli, + paymentMethod, + cusProduct, + }), newSubs: [subscription], }); } @@ -182,6 +171,7 @@ const handleCusProductDeleted = async ({ org, env, curCusProduct: curMainProduct || undefined, + logger, }); await cancelCusProductSubscriptions({ @@ -215,19 +205,24 @@ export const handleSubscriptionDeleted = async ({ env, }); + const stripeCli = createStripeCli({ + org, + env, + }); + if (activeCusProducts.length === 0) { - console.log( - ` ⚠️ no customer products found with stripe sub id: ${subscription.id}`, - ); - if (subscription.livemode) { - throw new RecaseError({ - message: `Stripe subscription.deleted (live): no customer products found, subscription: ${subscription.id}`, - code: ErrCode.NoActiveCusProducts, - statusCode: 200, - }); + logger.warn( + `subscription.deleted: ${subscription.id} - no customer products found`, + ); + return; } + } + if (subscription.cancellation_details?.comment === "autumn_upgrade") { + logger.info( + `sub.deleted: ${subscription.id} from autumn upgrade, skipping`, + ); return; } @@ -240,11 +235,10 @@ export const handleSubscriptionDeleted = async ({ handleCusProductDeleted({ req, db, + stripeCli, cusProduct, subscription, logger, - env, - org, prematurelyCanceled, }), ); diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index 418f157bd..29ec51b6f 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -206,6 +206,7 @@ export const handleSubscriptionUpdated = async ({ }, startsAt: fullSub.current_period_end * 1000, sendWebhook: false, + logger, }); if (fullCusProduct) { diff --git a/server/src/external/stripe/webhookUtils/webhookUtils.ts b/server/src/external/stripe/webhookUtils/webhookUtils.ts new file mode 100644 index 000000000..f72c0e8c9 --- /dev/null +++ b/server/src/external/stripe/webhookUtils/webhookUtils.ts @@ -0,0 +1,49 @@ +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { + cusProductToEnts, + cusProductToPrices, + cusProductToProduct, +} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { + AppEnv, + Entity, + Feature, + FullCusProduct, + Organization, +} from "@autumn/shared"; +import Stripe from "stripe"; + +export const webhookToAttachParams = ({ + req, + stripeCli, + paymentMethod, + cusProduct, + entities, +}: { + req: ExtendedRequest; + stripeCli: Stripe; + paymentMethod?: Stripe.PaymentMethod | null; + cusProduct: FullCusProduct; + entities?: Entity[]; +}): AttachParams => { + const fullProduct = cusProductToProduct({ cusProduct }); + + return { + stripeCli, + paymentMethod, + + customer: cusProduct.customer!, + org: req.org, + products: [fullProduct], + prices: cusProductToPrices({ cusProduct }), + entitlements: cusProductToEnts({ cusProduct }), + features: req.features, + freeTrial: cusProduct.free_trial || null, + optionsList: cusProduct.options, + cusProducts: [cusProduct], + + internalEntityId: cusProduct.internal_entity_id || undefined, + entities: entities || [], + }; +}; diff --git a/server/src/index.ts b/server/src/index.ts index 5ebb286f5..5b5af8d6b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -108,11 +108,7 @@ const init = async () => { const methodColor: any = methodToColor[method] || chalk.gray; - console.log( - `${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${methodColor( - method, - )} ${chalk.white(path)}`, - ); + console.log(`${methodColor(method).padEnd(18)} ${path}`); next(); }); diff --git a/server/src/internal/api/entitled/checkUtils.ts b/server/src/internal/api/entitled/checkUtils.ts index 925d425b1..e56393613 100644 --- a/server/src/internal/api/entitled/checkUtils.ts +++ b/server/src/internal/api/entitled/checkUtils.ts @@ -3,7 +3,9 @@ import { isFeaturePriceItem } from "@/internal/products/product-items/getItemTyp import { APIVersion, + BillingInterval, Feature, + FreeTrial, FullCusProduct, FullCustomerEntitlement, Organization, @@ -14,6 +16,8 @@ import { import { getCheckPreview } from "./getCheckPreview.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { formatUnixToDateTime } from "@/utils/genUtils.js"; export const getBooleanEntitledResult = async ({ db, @@ -78,20 +82,58 @@ export const getBooleanEntitledResult = async ({ export const getOptions = ({ prodItems, features, + anchorToUnix, + proration, + now, + freeTrial, }: { prodItems: ProductItem[]; features: Feature[]; + anchorToUnix?: number; + proration?: { + start: number; + end: number; + }; + now?: number; + freeTrial?: FreeTrial | null; }) => { + now = now || Date.now(); + return prodItems .filter((i) => isFeaturePriceItem(i) && i.usage_model == UsageModel.Prepaid) .map((i) => { - let priceData = itemToPriceOrTiers(i); + const finalProration = getProration({ + anchorToUnix, + proration, + interval: (i.interval || BillingInterval.OneOff) as BillingInterval, + now, + }); + + let priceData = itemToPriceOrTiers({ + item: i, + proration: finalProration, + now, + }); + let actualPrice = itemToPriceOrTiers({ + item: i, + }); + + if (freeTrial) { + priceData = { + price: 0, + tiers: undefined, + }; + } + return { feature_id: i.feature_id, feature_name: features.find((f) => f.id == i.feature_id)?.name, billing_units: i.billing_units, included_usage: i.included_usage || 0, ...priceData, + + full_price: actualPrice?.price, + full_tiers: actualPrice?.tiers, }; }); }; diff --git a/server/src/internal/api/entitled/handlers/getAttachPreview.ts b/server/src/internal/api/entitled/handlers/getAttachPreview.ts index ad38feb86..d53041710 100644 --- a/server/src/internal/api/entitled/handlers/getAttachPreview.ts +++ b/server/src/internal/api/entitled/handlers/getAttachPreview.ts @@ -13,10 +13,10 @@ import { isProductUpgrade, } from "@/internal/products/productUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; + import { AppEnv, BillingType, - Customer, Feature, FullCusProduct, FullCustomer, diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 68867a1e2..6d80b799b 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -99,7 +99,7 @@ export const initCusProduct = ({ let trialEnds = trialEndsAt; if (!trialEndsAt && freeTrial) { - trialEnds = freeTrialToStripeTimestamp(freeTrial)! * 1000; + trialEnds = freeTrialToStripeTimestamp({ freeTrial })! * 1000; } return { @@ -270,6 +270,7 @@ export const createFullCusProduct = async ({ isDowngrade = false, scenario = "default", sendWebhook = true, + logger, }: { db: DrizzleCli; attachParams: InsertCusProductParams; @@ -293,16 +294,10 @@ export const createFullCusProduct = async ({ isDowngrade?: boolean; scenario?: string; sendWebhook?: boolean; + logger: any; }) => { disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial; - const logger = createLogtailWithContext({ - action: LoggerAction.CreateFullCusProduct, - org_slug: attachParams.org.slug, - org_id: attachParams.org.id, - attachParams, - }); - let { customer, product, prices, entitlements, optionsList, freeTrial, org } = attachParams; @@ -369,8 +364,6 @@ export const createFullCusProduct = async ({ cusEnts.push(cusEnt); } - // Perform deductions on new cus ents... - let deductedCusEnts = addExistingUsagesToCusEnts({ cusEnts: cusEnts, entitlements: entitlements, diff --git a/server/src/internal/customers/add-product/handleAddFreeProduct.ts b/server/src/internal/customers/add-product/handleAddFreeProduct.ts index 90646d11a..bc71186e1 100644 --- a/server/src/internal/customers/add-product/handleAddFreeProduct.ts +++ b/server/src/internal/customers/add-product/handleAddFreeProduct.ts @@ -17,6 +17,7 @@ export const handleAddFreeProduct = async ({ res: any; attachParams: AttachParams; }) => { + const logger = req.logtail; const { customer, products } = attachParams; console.log( @@ -29,10 +30,10 @@ export const handleAddFreeProduct = async ({ for (const product of products) { await createFullCusProduct({ db: req.db, - attachParams: attachToInsertParams(attachParams, product), subscriptionId: undefined, billLaterOnly: false, + logger, }); } diff --git a/server/src/internal/customers/add-product/handleAddProduct.ts b/server/src/internal/customers/add-product/handleAddProduct.ts index cc9abc111..92aa79f4a 100644 --- a/server/src/internal/customers/add-product/handleAddProduct.ts +++ b/server/src/internal/customers/add-product/handleAddProduct.ts @@ -184,6 +184,7 @@ export const handleBillNowPrices = async ({ : undefined, carryExistingUsages, scenario: AttachScenario.New, + logger, }), ); } @@ -414,9 +415,9 @@ export const handleOneOffPrices = async ({ batchInsert.push( createFullCusProduct({ db: req.db, - attachParams: attachToInsertParams(attachParams, product), lastInvoiceId: stripeInvoice.id, + logger, }), ); } @@ -529,6 +530,7 @@ export const handleAddProduct = async ({ billLaterOnly: true, carryExistingUsages, keepResetIntervals, + logger, }), ); } diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 544489cbe..7e33c96b6 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -74,7 +74,7 @@ export const handleCreateCheckout = async ({ ? { trial_end: freeTrial && !attachParams.disableFreeTrial - ? freeTrialToStripeTimestamp(freeTrial) + ? freeTrialToStripeTimestamp({ freeTrial }) : undefined, // metadata: subMeta, billing_cycle_anchor: billingCycleAnchorUnixSeconds, diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index 3194b62cc..82e1e1bcd 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -104,7 +104,7 @@ const initCusEntNextResetAt = ({ // 4. Calculate next reset at... let nextResetAtCalculated = null; - let trialEndTimestamp = freeTrialToStripeTimestamp(freeTrial); + let trialEndTimestamp = freeTrialToStripeTimestamp({ freeTrial }); if ( freeTrial && applyTrialToEntitlement(entitlement, freeTrial) && diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts index 74b6db8be..ed64bd0c0 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts @@ -1,36 +1,20 @@ import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; -import { - getStripeSchedules, - getStripeSubs, -} from "@/external/stripe/stripeSubUtils.js"; -import { getCusProductsWithStripeSubIds } from "@/internal/customers/change-product/handleDowngrade.js"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { cancelCurSubs } from "@/internal/customers/change-product/handleDowngrade/cancelCurSubs.js"; -import { - cancelFutureProductSchedule, - getScheduleIdsFromCusProducts, -} from "@/internal/customers/change-product/scheduleUtils.js"; import { updateScheduledSubWithNewItems } from "@/internal/customers/change-product/scheduleUtils/updateScheduleWithNewItems.js"; import { AttachParams, AttachResultSchema, } from "@/internal/customers/cusProducts/AttachParams.js"; -import { - APIVersion, - AttachScenario, - FullCusProduct, - SuccessCode, -} from "@autumn/shared"; -import { - handleNewScheduleForItemSet, - scheduleStripeSub, -} from "./scheduleStripeSub.js"; +import { APIVersion, AttachScenario, SuccessCode } from "@autumn/shared"; +import { handleNewScheduleForItemSet } from "./scheduleStripeSub.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { attachToInsertParams, isFreeProduct, } from "@/internal/products/productUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; + import { cusProductsToSchedules } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import Stripe from "stripe"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; @@ -143,6 +127,7 @@ export const handleScheduleFunction = async ({ disableFreeTrial: true, isDowngrade: true, scenario: newProductFree ? AttachScenario.Cancel : AttachScenario.Downgrade, + logger, }); // 8. Updating current cus product canceled_at... diff --git a/server/src/internal/customers/attach/attachFunctions/updateEntsFlow/handleEntsChangedFunction.ts b/server/src/internal/customers/attach/attachFunctions/updateEntsFlow/handleEntsChangedFunction.ts index 872be2b3a..0315388b0 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateEntsFlow/handleEntsChangedFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateEntsFlow/handleEntsChangedFunction.ts @@ -42,6 +42,7 @@ export const handleEntsChangedFunction = async ({ disableFreeTrial: false, keepResetIntervals: true, carryExistingUsages, + logger, }); logger.info("✅ Successfully updated entitlements for product"); diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts index fe7f27cd4..61677887a 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts @@ -1,41 +1,52 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { + autumnToStripeProrationBehavior, getStripeSubs, getUsageBasedSub, } from "@/external/stripe/stripeSubUtils.js"; +import { findSubItemForPrice } 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 { - Customer, ErrCode, Feature, FullCusProduct, FullCustomerEntitlement, FullCustomerPrice, - Organization, UsagePriceConfig, } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { Stripe } from "stripe"; +import { AttachConfig } from "../../models/AttachFlags.js"; export const updateFeatureQuantity = async ({ db, stripeCli, cusProduct, optionsToUpdate, + config, + logger, }: { db: DrizzleCli; stripeCli: Stripe; cusProduct: FullCusProduct; optionsToUpdate: any[]; + config: AttachConfig; + logger: any; }) => { const stripeSubs = await getStripeSubs({ stripeCli: stripeCli, subIds: cusProduct.subscription_ids || [], }); + const prorationBehavior = autumnToStripeProrationBehavior({ + prorationBehavior: config.proration, + }); + for (const options of optionsToUpdate) { const { new: newOptions, old: oldOptions } = options; const subToUpdate = await getUsageBasedSub({ @@ -57,44 +68,53 @@ export const updateFeatureQuantity = async ({ }); } - // Update subscription - // Get price - const relatedPrice = cusProduct.customer_prices.find( - (cusPrice: FullCustomerPrice) => - (cusPrice.price.config as UsagePriceConfig).internal_feature_id == - newOptions.internal_feature_id, - ); + const curPrices = cusProductToPrices({ cusProduct }); + const price = findPriceForFeature({ + prices: curPrices, + internalFeatureId: newOptions.internal_feature_id, + }); - let config = relatedPrice?.price.config as UsagePriceConfig; + if (!price) { + throw new RecaseError({ + message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`, + code: ErrCode.PriceNotFound, + }); + } - let subItem = subToUpdate?.items.data.find( - (item: Stripe.SubscriptionItem) => - item.price.id == config.stripe_price_id, - ); + let subItem = findSubItemForPrice({ + price, + subItems: subToUpdate.items.data, + }); if (!subItem) { - // Create new subscription item subItem = await stripeCli.subscriptionItems.create({ subscription: subToUpdate.id, - price: config.stripe_price_id as string, + price: price.config.stripe_price_id as string, quantity: newOptions.quantity, + proration_behavior: prorationBehavior, + payment_behavior: "error_if_incomplete", }); - console.log( - ` ✅ Successfully created subscription item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, + logger.info( + `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, ); } else { - // Update quantity await stripeCli.subscriptionItems.update(subItem.id, { quantity: newOptions.quantity, + proration_behavior: prorationBehavior, + payment_behavior: "error_if_incomplete", }); - console.log( - ` ✅ Successfully updated subscription item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, + logger.info( + `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`, ); } // Update cus ent - let difference = newOptions.quantity - oldOptions.quantity; + 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 == @@ -102,14 +122,10 @@ export const updateFeatureQuantity = async ({ ); if (cusEnt) { - let updates: any = { - balance: new Decimal(cusEnt?.balance || 0).plus(difference).toNumber(), - }; - - await CusEntService.update({ + await CusEntService.increment({ db, id: cusEnt.id, - updates, + amount: difference.toNumber(), }); } } diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index b8106eb65..2418a71ce 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -5,21 +5,23 @@ import { } from "../../../cusProducts/AttachParams.js"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; import { updateFeatureQuantity } from "./updateFeatureQuantity.js"; +import { AttachConfig } from "@autumn/shared"; export const handleUpdateQuantityFunction = async ({ req, res, attachParams, + config, }: { req: any; res: any; attachParams: AttachParams; + config: AttachConfig; }) => { // 2. Update quantities const optionsToUpdate = attachParams.optionsToUpdate!; const { customer } = attachParams; - const { curSameProduct } = attachParamToCusProducts({ attachParams }); await updateFeatureQuantity({ @@ -27,12 +29,14 @@ export const handleUpdateQuantityFunction = async ({ stripeCli: attachParams.stripeCli, cusProduct: curSameProduct!, optionsToUpdate: optionsToUpdate!, + config, + logger: req.logtail, }); res.status(200).json( AttachResultSchema.parse({ customer_id: customer.id || customer.internal_id, - product_id: curSameProduct!.product.id, + product_ids: attachParams.products.map((p) => p.id), code: SuccessCode.FeaturesUpdated, message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`, }), diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFunction.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFunction.ts index 267e840a1..27147397c 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFunction.ts @@ -3,11 +3,11 @@ import { AttachParams, AttachResultSchema, } from "../../../cusProducts/AttachParams.js"; -import { handleStripeSubUpdate } from "@/internal/customers/change-product/handleUpgrade.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { billForRemainingUsages } from "@/internal/customers/change-product/billRemainingUsages.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; + import { APIVersion, AttachScenario, @@ -15,15 +15,12 @@ import { ProcessorType, SuccessCode, } from "@autumn/shared"; + import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; - import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; - -import { AttachConfig } from "../../models/AttachFlags.js"; - +import { AttachConfig, ProrationBehavior } from "../../models/AttachFlags.js"; import { updateStripeSubs } from "./updateStripeSubs.js"; -import Stripe from "stripe"; export const handleUpgradeFunction = async ({ req, @@ -55,7 +52,7 @@ export const handleUpgradeFunction = async ({ subIds: curCusProduct.subscription_ids, }); - logger.info("1. Updating current subscription in Stripe"); + logger.info("1. Updating current subscriptions in Stripe"); let { newSubs } = await updateStripeSubs({ db: req.db, curCusProduct, @@ -72,11 +69,11 @@ export const handleUpgradeFunction = async ({ attachParams, curCusProduct, newSubs, - config, logger, + billImmediately: config.proration === ProrationBehavior.Immediately, }); - logger.info("3. Expiring old cus product & other subs"); + logger.info("3. Expiring old cus product"); await CusProductService.update({ db: req.db, cusProductId: curCusProduct.id, @@ -92,11 +89,6 @@ export const handleUpgradeFunction = async ({ }, }); - // Cancel other subscriptions (since not updated) - for (const sub of stripeSubs.slice(1)) { - await stripeCli.subscriptions.cancel(sub.id); - } - // Insert new cus product logger.info("4. Creating new cus product"); await createFullCusProduct({ @@ -111,6 +103,7 @@ export const handleUpgradeFunction = async ({ carryExistingUsages: carryUsage, carryOverTrial: true, scenario: AttachScenario.Upgrade, + logger, }); // Insert invoices diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSubs.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSubs.ts index 409f9991b..034306f60 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSubs.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSubs.ts @@ -59,8 +59,14 @@ export const updateStripeSubs = async ({ let trialEnd = config.disableTrial ? null - : freeTrialToStripeTimestamp(attachParams.freeTrial); + : freeTrialToStripeTimestamp({ + freeTrial: attachParams.freeTrial, + now: attachParams.now, + }); + // console.log("Now:", formatUnixToDateTime(attachParams.now)); + // console.log("Free trial:", attachParams.freeTrial); + // console.log("Trial end:", formatUnixToDateTime(trialEnd * 1000)); // 2. Update current subscription let newSubs: Stripe.Subscription[] = []; const subUpdateRes = await updateStripeSubscription({ @@ -97,8 +103,18 @@ export const updateStripeSubs = async ({ logger, }); - // OPTIMIZE GET STRIPE NOW? - // 4. Create subs for other intervals + // 4. Cancel other subscriptions + for (const sub of stripeSubs.slice(1)) { + logger.info(`1.4: canceling additional sub: ${sub.id}`); + await stripeCli.subscriptions.cancel(sub.id, { + prorate: true, + cancellation_details: { + comment: "autumn_upgrade", + }, + }); + } + + // 5. Create subs for other intervals const now = await getStripeNow({ stripeCli, stripeSub: subUpdate }); for (const itemSet of itemSets.slice(1)) { const newSub = (await createStripeSub({ diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index d7aee2f48..ee6824a3a 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -1,15 +1,6 @@ import { Router } from "express"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { z } from "zod"; -import { - APIVersion, - BillingType, - FeatureOptions, - FeatureOptionsSchema, - FullCusProduct, - ProductItem, - ProductItemSchema, -} from "@autumn/shared"; +import RecaseError from "@/utils/errorUtils.js"; +import { APIVersion, BillingType, FullCusProduct } from "@autumn/shared"; import { ErrCode } from "@/errors/errCodes.js"; import { createStripeCusIfNotExists, @@ -45,19 +36,13 @@ import { orgToVersion } from "@/utils/versionUtils.js"; import { handleExistingProduct } from "@/internal/customers/add-product/handleExistingProduct.js"; import { handleAddFreeProduct } from "@/internal/customers/add-product/handleAddFreeProduct.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; -import { handleChangeProduct } from "@/internal/customers/change-product/handleChangeProduct.js"; import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; import { AttachBodySchema } from "./models/AttachBody.js"; import { processAttachBody } from "./attachUtils/processAttachBody.js"; -import { getAttachBranch } from "./attachUtils/getAttachBranch.js"; -import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; -import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; -import { getAttachConfig } from "./attachUtils/getAttachConfig.js"; -import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js"; -import { getAttachParams } from "./attachUtils/getAttachParams.js"; +import { handleAttach } from "./handleAttach.js"; export const attachRouter = Router(); @@ -258,68 +243,6 @@ export const customerHasPm = async ({ return notNullOrUndefined(paymentMethod) ? true : false; }; -const handleAttachNew = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "attach", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - await handleAttachRaceCondition({ req, res }); - - const attachBody = AttachBodySchema.parse(req.body); - const logger = req.logtail; - - const { attachParams, customPrices, customEnts } = await getAttachParams({ - req, - attachBody, - }); - - // Handle existing product - const branch = await getAttachBranch({ - req, - attachBody, - attachParams, - }); - - const { flags, config } = await getAttachConfig({ - req, - attachParams, - attachBody, - branch, - }); - - await handleAttachErrors({ - attachParams, - attachBody, - branch, - flags, - config, - }); - - await checkStripeConnections({ req, attachParams }); - await createStripePrices({ - attachParams, - useCheckout: config.onlyCheckout, - req, - logger, - }); - await insertCustomItems({ - db: req.db, - customPrices: customPrices || [], - customEnts: customEnts || [], - }); - - await runAttachFunction({ - req, - res, - attachParams, - branch, - attachBody, - config, - }); - }, - }); - const handleAttachOld = async (req: any, res: any) => routeHandler({ action: "attach", @@ -508,17 +431,17 @@ const handleAttachOld = async (req: any, res: any) => return; } - // SCENARIO 4: Switching product - if (curCusProduct) { - logger.info("SCENARIO 3: SWITCHING PRODUCT"); - await handleChangeProduct({ - req, - res, - attachParams, - curCusProduct, - }); - return; - } + // // SCENARIO 4: Switching product + // if (curCusProduct) { + // logger.info("SCENARIO 3: SWITCHING PRODUCT"); + // await handleChangeProduct({ + // req, + // res, + // attachParams, + // curCusProduct, + // }); + // return; + // } // SCENARIO 5: No existing product, not free product logger.info("SCENARIO 4: ADDING PRODUCT"); @@ -530,6 +453,6 @@ const handleAttachOld = async (req: any, res: any) => }, }); -attachRouter.post("", handleAttachNew); +attachRouter.post("", handleAttach); attachRouter.post("/preview", handleAttachPreview); // attachRouter.post("", handleAttachOld); diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index d18a1b800..1f1724800 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -3,7 +3,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { AttachBody } from "../models/AttachBody.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; import { notNullish } from "@/utils/genUtils.js"; -import { AttachBranch } from "../models/AttachBranch.js"; +import { AttachBranch } from "@autumn/shared"; import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; @@ -21,6 +21,7 @@ import { mapToProductItems } from "@/internal/products/productV2Utils.js"; import { productsAreSame } from "@/internal/products/compareProductUtils.js"; import { StatusCodes } from "http-status-codes"; import { isTrialing } from "../../cusProducts/cusProductUtils.js"; +import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils.js"; const checkMultiProductErrors = async ({ attachParams, @@ -109,17 +110,18 @@ const checkSameCustom = async ({ }) => { let product = attachParams.products[0]; - let { itemsSame, freeTrialsSame, onlyEntsChanged } = productsAreSame({ - v1Product1: { - ...product, - prices: attachParams.prices, - entitlements: attachParams.entitlements, - free_trial: attachParams.freeTrial, - }, - v1Product2: cusProductToProduct({ cusProduct: curSameProduct }), + let { itemsSame, freeTrialsSame, onlyEntsChanged, newItems } = + productsAreSame({ + newProductV1: { + ...product, + prices: attachParams.prices, + entitlements: attachParams.entitlements, + free_trial: attachParams.freeTrial, + }, + curProductV1: cusProductToProduct({ cusProduct: curSameProduct }), - features: attachParams.features, - }); + features: attachParams.features, + }); if (itemsSame && freeTrialsSame) { throw new RecaseError({ @@ -137,8 +139,10 @@ const checkSameCustom = async ({ const getSameProductBranch = async ({ attachParams, + fromPreview, }: { attachParams: AttachParams; + fromPreview?: boolean; }) => { let product = attachParams.products[0]; @@ -199,6 +203,12 @@ const getSameProductBranch = async ({ return AttachBranch.Renew; } + if (fromPreview) { + if (hasPrepaidPrice({ prices: attachParams.prices })) { + return AttachBranch.UpdatePrepaidQuantity; + } + } + // Invalid, can't attach same product throw new RecaseError({ message: `Product ${product.name} is already attached, can't attach again`, @@ -243,10 +253,12 @@ export const getAttachBranch = async ({ req, attachBody, attachParams, + fromPreview, }: { req: ExtendedRequest; attachBody: AttachBody; attachParams: AttachParams; + fromPreview?: boolean; }) => { // 1. Multi product if (notNullish(attachBody.product_ids)) { @@ -266,7 +278,12 @@ export const getAttachBranch = async ({ // 3. Same product if (curSameProduct) { - return await getSameProductBranch({ attachParams }); + return await getSameProductBranch({ attachParams, fromPreview }); + } + + let product = attachParams.products[0]; + if (product.is_add_on) { + return AttachBranch.AddOn; } // 4. Main product exists @@ -274,12 +291,5 @@ export const getAttachBranch = async ({ return getChangeProductBranch({ attachParams }); } - // 5. New product! - let product = attachParams.products[0]; - - if (product.is_add_on) { - return AttachBranch.AddOn; - } else { - return AttachBranch.New; - } + return AttachBranch.New; }; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts index be0a8d24e..86c0130cb 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts @@ -1,11 +1,12 @@ -import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; import { AttachConfig, AttachFlags } from "../models/AttachFlags.js"; -import { AttachBranch } from "../models/AttachBranch.js"; +import { AttachBranch } from "@autumn/shared"; import { AttachBody } from "../models/AttachBody.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; import { nullish } from "@/utils/genUtils.js"; import { ProrationBehavior } from "../../change-product/handleUpgrade.js"; +import { AppEnv } from "@autumn/shared"; +import { Organization } from "@autumn/shared"; export const getAttachConfig = async ({ req, @@ -40,8 +41,22 @@ export const getAttachConfig = async ({ ? ProrationBehavior.Immediately : ProrationBehavior.NextBilling, disableTrial: - branch === AttachBranch.NewVersion || attachBody.free_trial === false, + branch === AttachBranch.NewVersion || + branch == AttachBranch.Downgrade || + attachBody.free_trial === false, }; return { flags, config }; }; + +const webhookToConfig = ({ org, env }: { org: Organization; env: AppEnv }) => { + const config: AttachConfig = { + branch: AttachBranch.NewVersion, // not needed... + carryUsage: false, // not needed... + onlyCheckout: false, + proration: ProrationBehavior.Immediately, + disableTrial: false, + }; + + return config; +}; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index 8232aecd4..f68a09bbf 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -3,20 +3,19 @@ import { AttachParams, AttachResultSchema, } from "../../cusProducts/AttachParams.js"; -import { AttachBranch, AttachFunction } from "../models/AttachBranch.js"; -import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { nullish } from "@/utils/genUtils.js"; -import { handleUpgrade } from "../../change-product/handleUpgrade.js"; +import { AttachBranch, AttachFunction } from "@autumn/shared"; import { handleUpgradeFunction } from "../attachFunctions/upgradeFlow/handleUpgradeFunction.js"; import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js"; import { handleAddProduct } from "../../add-product/handleAddProduct.js"; import { AttachBody } from "../models/AttachBody.js"; -import { AttachConfig, AttachFlags } from "../models/AttachFlags.js"; +import { AttachConfig } from "../models/AttachFlags.js"; import { handleScheduleFunction } from "../attachFunctions/scheduleFlow/handleScheduleFunction.js"; import { cancelFutureProductSchedule } from "../../change-product/scheduleUtils.js"; import { handleEntsChangedFunction } from "../attachFunctions/updateEntsFlow/handleEntsChangedFunction.js"; import { handleUpdateQuantityFunction } from "../attachFunctions/updateQuantityFlow/updateQuantityFlow.js"; import { SuccessCode } from "@autumn/shared"; +import { attachParamToCusProducts } from "./convertAttachParams.js"; +import chalk from "chalk"; /* 1. If from new version, free trial should just carry over @@ -130,10 +129,23 @@ export const runAttachFunction = async ({ const customer = attachParams.customer; const org = attachParams.org; const productIdsStr = attachParams.products.map((p) => p.id).join(", "); + const { curMainProduct, curSameProduct, curScheduledProduct } = + attachParamToCusProducts({ + attachParams, + }); + + logger.info(`--------------------------------`); logger.info( `ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}`, ); - logger.info(`Branch: ${branch}, Function: ${attachFunction}`); + logger.info( + `Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`, + { + curMainProduct: curMainProduct?.product.id, + curSameProduct: curSameProduct?.product.id, + curScheduledProduct: curScheduledProduct?.product.id, + }, + ); // 1. Cancel future schedule before creating a new one... await cancelFutureProductSchedule({ @@ -208,6 +220,7 @@ export const runAttachFunction = async ({ req, res, attachParams, + config, }); } }; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/getAttachParams.ts index 72a611a09..07c5ff27a 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachParams.ts @@ -4,8 +4,12 @@ import { processAttachBody } from "./processAttachBody.js"; import { orgToVersion } from "@/utils/versionUtils.js"; import { APIVersion } from "@autumn/shared"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; +import { + getCusPaymentMethod, + getStripeCus, +} from "@/external/stripe/stripeCusUtils.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; export const getAttachParams = async ({ req, @@ -45,13 +49,32 @@ export const getAttachParams = async ({ : undefined; const stripeCli = createStripeCli({ org, env }); - const paymentMethod = await getCusPaymentMethod({ - stripeCli, - stripeId: customer.processor?.id, - }); + const [paymentMethod, { stripeCus, now }] = await Promise.all([ + getCusPaymentMethod({ + stripeCli, + stripeId: customer.processor?.id, + }), + (async () => { + try { + const stripeCus = await getStripeCus({ + stripeCli, + stripeId: customer.processor?.id, + }); + const now = await getStripeNow({ + stripeCli, + stripeCus, + }); + return { stripeCus, now }; + } catch (error) { + return { stripeCus: undefined, now: undefined }; + } + })(), + ]); const attachParams: AttachParams = { stripeCli, + stripeCus, + now, paymentMethod, customer, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 56912f2a2..805c8f473 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -2,7 +2,7 @@ import RecaseError from "@/utils/errorUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; import { StatusCodes } from "http-status-codes"; import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { AttachBranch } from "../models/AttachBranch.js"; +import { AttachBranch } from "@autumn/shared"; import { AttachBody } from "../models/AttachBody.js"; import { AttachConfig, AttachFlags } from "../models/AttachFlags.js"; import { diff --git a/server/src/internal/customers/attach/attachUtils/processAttachBody.ts b/server/src/internal/customers/attach/attachUtils/processAttachBody.ts index 4dfec9e5b..fef762bde 100644 --- a/server/src/internal/customers/attach/attachUtils/processAttachBody.ts +++ b/server/src/internal/customers/attach/attachUtils/processAttachBody.ts @@ -25,6 +25,10 @@ import { } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemInitUtils.js"; import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils.js"; +import { + isMainProduct, + oneOffOrAddOn, +} from "@/internal/products/productUtils/classifyProduct.js"; const getProductsForAttach = async ({ req, @@ -132,13 +136,15 @@ const getPricesAndEnts = async ({ }); } + const prodIsMain = isMainProduct({ product: products[0], prices }); + return { optionsList: mapOptionsList({ optionsInput, features, prices, // to check if it fails for multi prod attach... - curCusProduct: products[0].is_add_on ? curSameProduct : curMainProduct, + curCusProduct: prodIsMain ? curMainProduct : curSameProduct, }), prices, entitlements, @@ -189,12 +195,14 @@ const getPricesAndEnts = async ({ multipleAllowed: org.config.multiple_trials, }); + const prodIsMain = isMainProduct({ product: products[0], prices }); + return { optionsList: mapOptionsList({ optionsInput, features, prices, - curCusProduct: product.is_add_on ? curSameProduct : curMainProduct, + curCusProduct: prodIsMain ? curMainProduct : curSameProduct, }), prices, entitlements: getEntsWithFeature({ diff --git a/server/src/internal/customers/attach/handleAttach.ts b/server/src/internal/customers/attach/handleAttach.ts new file mode 100644 index 000000000..8311f47ec --- /dev/null +++ b/server/src/internal/customers/attach/handleAttach.ts @@ -0,0 +1,73 @@ +import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js"; +import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import { AttachBodySchema } from "./models/AttachBody.js"; +import { getAttachParams } from "./attachUtils/getAttachParams.js"; +import { getAttachBranch } from "./attachUtils/getAttachBranch.js"; +import { getAttachConfig } from "./attachUtils/getAttachConfig.js"; +import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; +import { checkStripeConnections, createStripePrices } from "./attachRouter.js"; +import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; +import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; + +export const handleAttach = async (req: any, res: any) => + routeHandler({ + req, + res, + action: "attach", + handler: async (req: ExtendedRequest, res: ExtendedResponse) => { + await handleAttachRaceCondition({ req, res }); + + const attachBody = AttachBodySchema.parse(req.body); + const logger = req.logtail; + + const { attachParams, customPrices, customEnts } = await getAttachParams({ + req, + attachBody, + }); + + // Handle existing product + const branch = await getAttachBranch({ + req, + attachBody, + attachParams, + }); + + const { flags, config } = await getAttachConfig({ + req, + attachParams, + attachBody, + branch, + }); + + await handleAttachErrors({ + attachParams, + attachBody, + branch, + flags, + config, + }); + + await checkStripeConnections({ req, attachParams }); + await createStripePrices({ + attachParams, + useCheckout: config.onlyCheckout, + req, + logger, + }); + await insertCustomItems({ + db: req.db, + customPrices: customPrices || [], + customEnts: customEnts || [], + }); + + await runAttachFunction({ + req, + res, + attachParams, + branch, + attachBody, + config, + }); + }, + }); diff --git a/server/src/internal/customers/attach/handleAttachPreview/attachPreviewUtils.ts b/server/src/internal/customers/attach/handleAttachPreview/attachPreviewUtils.ts new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/server/src/internal/customers/attach/handleAttachPreview/attachPreviewUtils.ts @@ -0,0 +1 @@ + diff --git a/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts new file mode 100644 index 000000000..47b2b659a --- /dev/null +++ b/server/src/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.ts @@ -0,0 +1,57 @@ +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { + attachParamsToProduct, + attachParamToCusProducts, +} from "../attachUtils/convertAttachParams.js"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { getOptions } from "@/internal/api/entitled/checkUtils.js"; +import { UsageModel } from "@autumn/shared"; + +export const getDowngradeProductPreview = async ({ + attachParams, + now, +}: { + attachParams: AttachParams; + now: number; +}) => { + const newProduct = attachParamsToProduct({ attachParams }); + + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + const stripeSubs = await getStripeSubs({ + stripeCli: attachParams.stripeCli, + subIds: curMainProduct?.subscription_ids || [], + }); + + const anchorToUnix = stripeSubs[0].current_period_end * 1000; + + let items = getItemsForNewProduct({ + newProduct, + attachParams, + now, + // anchorToUnix, + }); + + items = items.filter((item) => item.usage_model !== UsageModel.Prepaid); + + let options = getOptions({ + prodItems: mapToProductItems({ + prices: newProduct.prices, + entitlements: newProduct.entitlements, + features: attachParams.features, + }), + features: attachParams.features, + // anchorToUnix, + }); + + return { + currency: attachParams.org.default_currency, + due_next_cycle: { + line_items: items, + due_at: anchorToUnix, + }, + + options, + }; +}; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index eda4b5bce..9c078cd01 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -1,81 +1,54 @@ -import { Feature, FullProduct, Organization, UsageModel } from "@autumn/shared"; -import { mapToProductV2 } from "@/internal/products/productV2Utils.js"; -import { isOneOff } from "@/internal/products/productUtils.js"; - -import { - isFeatureItem, - isPriceItem, -} from "@/internal/products/product-items/getItemType.js"; -import { - getPricecnPrice, - sortProductItems, -} from "@/internal/products/pricecn/pricecnUtils.js"; +import { BillingInterval } from "@autumn/shared"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; -import { AttachScenario } from "@autumn/shared"; -import { getItemDescription } from "../../previews/checkProductUtils.js"; +import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; export const getNewProductPreview = async ({ - org, - product, - features, + attachParams, + now, }: { - org: Organization; - product: FullProduct; - features: Feature[]; + attachParams: AttachParams; + now: number; }) => { - let productV2 = mapToProductV2({ - product, - features, + const { org } = attachParams; + const newProduct = attachParamsToProduct({ attachParams }); + + let anchorToUnix = undefined; + if (org.config.anchor_start_of_month) { + anchorToUnix = getNextStartOfMonthUnix(BillingInterval.Month); + } + + const items = getItemsForNewProduct({ + newProduct, + attachParams, + now, + anchorToUnix, }); - let sortedItems = sortProductItems(productV2.items, features); - - let lineItems = sortedItems - .filter((i) => !isFeatureItem(i) && i.usage_model != UsageModel.Prepaid) - .map((i, index) => { - let pricecnPrice = getPricecnPrice({ - org, - items: [i], - features, - isMainPrice: index == 0, - }); - - let description = getItemDescription({ - item: i, - features, - product: productV2, - org, - }); - - return { - description, - price: `${pricecnPrice.primaryText} ${pricecnPrice.secondaryText}`, - }; - }); - - let dueToday = Number( - sortedItems - .filter((i) => isPriceItem(i)) - .reduce((sum, i) => sum + i.price!, 0) - .toFixed(2), - ); + const dueTodayAmt = items.reduce((acc, item) => { + return acc + (item.amount ?? 0); + }, 0); let options = getOptions({ - prodItems: productV2.items, - features, + prodItems: mapToProductItems({ + prices: newProduct.prices, + entitlements: newProduct.entitlements, + features: attachParams.features, + }), + features: attachParams.features, + anchorToUnix, }); return { - scenario: AttachScenario.New, - product_id: product.id, - product_name: product.name, - recurring: !isOneOff(product.prices), - - items: lineItems, - options, + currency: attachParams.org.default_currency, due_today: { - price: dueToday, - currency: org.default_currency || "USD", + line_items: items, + total: dueTodayAmt, }, + + options, }; }; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts new file mode 100644 index 000000000..ab17f595d --- /dev/null +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts @@ -0,0 +1,31 @@ +import { productsAreSame } from "@/internal/products/compareProductUtils.js"; +import { + attachParamsToProduct, + attachParamToCusProducts, +} from "../attachUtils/convertAttachParams.js"; +import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; + +export const getUpdateEntsPreview = async ({ + req, + attachParams, + now, +}: { + req: any; + attachParams: any; + now: number; +}) => { + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + const curProduct = cusProductToProduct({ cusProduct: curMainProduct! }); + const newProduct = attachParamsToProduct({ attachParams }); + const features = attachParams.features; + + const res = productsAreSame({ + newProductV1: newProduct, + curProductV1: curProduct, + features, + }); + + return { + new_items: res.newItems, + }; +}; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 70943ee8d..1338217f5 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -1,52 +1,92 @@ -import { - BillingType, - Feature, - FullProduct, - Organization, -} from "@autumn/shared"; import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { CusProductService } from "../../cusProducts/CusProductService.js"; import { attachParamsToProduct, attachParamToCusProducts, } from "../attachUtils/convertAttachParams.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; -import { getNextCycle } from "../attachFunctions/upgradeFlow/upgradeUtils.js"; -import Stripe from "stripe"; -import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { cusProductToPrices } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; -import { formatUnixToDateTime } from "@/utils/genUtils.js"; -import { Decimal } from "decimal.js"; -import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; -import { - newPriceToInvoiceDescription, - priceToInvoiceDescription, -} from "@/internal/invoices/invoiceFormatUtils.js"; -import { - getBillingType, - getPriceEntitlement, - getPriceForOverage, -} from "@/internal/products/prices/priceUtils.js"; -import { getCusPriceUsage } from "../../cusProducts/cusPrices/cusPriceUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; -import { getSubItemAmount } from "@/external/stripe/stripeSubUtils/getSubItemAmount.js"; -import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; -import { mapToProductItems } from "@/internal/products/productV2Utils.js"; -import { getExistingUsageFromCusProducts } from "../../cusProducts/cusEnts/cusEntUtils.js"; import { - isFixedPrice, - isUsagePrice, -} from "@/internal/products/prices/priceUtils/usagePriceUtils.js"; -import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; + getFirstInterval, + getLastInterval, +} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { subToAutumnInterval } from "@/external/stripe/utils.js"; +import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; +import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; +import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; +import { getOptions } from "@/internal/api/entitled/checkUtils.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import Stripe from "stripe"; +import { + AttachBranch, + BillingInterval, + FreeTrial, + Price, + UsageModel, +} from "@autumn/shared"; +import { + addBillingIntervalUnix, + getAlignedIntervalUnix, +} from "@/internal/products/prices/billingIntervalUtils.js"; +import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.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 getUpgradeProductPreview = async ({ req, attachParams, + branch, + now, }: { req: ExtendedRequest; attachParams: AttachParams; + branch: AttachBranch; + now: number; }) => { const { logtail: logger } = req; @@ -54,108 +94,90 @@ export const getUpgradeProductPreview = async ({ const { curMainProduct } = attachParamToCusProducts({ attachParams }); const curCusProduct = curMainProduct!; - const curPrices = cusProductToPrices({ cusProduct: curCusProduct }); const stripeSubs = await getStripeSubs({ stripeCli, - subIds: curCusProduct.subscription_ids || [], + subIds: curCusProduct?.subscription_ids || [], expand: ["items.data.price.tiers"], }); - const now = await getStripeNow({ stripeCli, stripeSub: stripeSubs[0] }); - - // Get prorated refunds for old product - for (const sub of stripeSubs) { - for (const item of sub.items.data) { - const price = findPriceInStripeItems({ - prices: curPrices, - subItem: item, - }); - - if (!price) continue; - const billingType = getBillingType(price.config); - if (billingType == BillingType.UsageInArrear) continue; - - const totalAmount = getSubItemAmount({ subItem: item }); - - const periodEnd = sub.current_period_end * 1000; - const periodStart = sub.current_period_start * 1000; - - if (now < periodEnd) { - const proratedAmount = calculateProrationAmount({ - periodEnd, - periodStart, - now, - amount: totalAmount, - }); - - const description = priceToInvoiceDescription({ - price, - cusProduct: curCusProduct, - quantity: item.quantity, - logger, - }); - - console.log("Item:", description); - console.log("Period ends:", formatUnixToDateTime(periodEnd)); - console.log("Prorated amount: ", proratedAmount); - console.log("--------------------------------"); - } - } - } + let curPreviewItems = getItemsForCurProduct({ + stripeSubs, + attachParams, + now, + logger, + }); // Get prorated amounts for new product const newProduct = attachParamsToProduct({ attachParams }); - - // Check if new product will have cycle reset const firstInterval = getFirstInterval({ prices: newProduct.prices }); const prevInterval = subToAutumnInterval(stripeSubs[0]); const cycleWillReset = prevInterval !== firstInterval; - console.log("Cycle will reset: ", cycleWillReset); + const newPreviewItems = getItemsForNewProduct({ + newProduct, + attachParams, + now, + anchorToUnix: !cycleWillReset + ? stripeSubs[0].current_period_end * 1000 + : undefined, + freeTrial: attachParams.freeTrial, + }).filter((item) => notNullish(item.amount) && item.amount != 0); - for (const price of newProduct.prices) { - const ent = getPriceEntitlement(price, newProduct.entitlements); - const billingType = getBillingType(price.config); + const lastInterval = getLastInterval({ prices: newProduct.prices }); + const nextCycleAt = getNextCycleAt({ + prices: newProduct.prices, + stripeSubs, + willCycleReset: cycleWillReset, + interval: lastInterval, + now, + freeTrial: attachParams.freeTrial, + }); - if ( - billingType == BillingType.UsageInArrear || - billingType == BillingType.UsageInAdvance - ) { - continue; - } + let nextCycleItems = getItemsForNewProduct({ + newProduct, + attachParams, + interval: attachParams.freeTrial ? undefined : lastInterval, + }); - let amount, usage; - if (isFixedPrice({ price })) { - amount = getPriceForOverage(price); - } else { - usage = getExistingUsageFromCusProducts({ - entitlement: ent, - cusProducts: attachParams.cusProducts, - entities: attachParams.entities, - carryExistingUsages: undefined, - internalEntityId: attachParams.internalEntityId, - }); + let items = [...curPreviewItems, ...newPreviewItems]; - const overage = new Decimal(usage).sub(ent.allowance!).toNumber(); - amount = getPriceForOverage(price, overage); - } + const dueTodayAmt = items.reduce((acc, item) => { + return acc + (item.amount ?? 0); + }, 0); - const description = newPriceToInvoiceDescription({ - price, - product: newProduct, - quantity: usage, - }); + let options = getOptions({ + prodItems: mapToProductItems({ + prices: newProduct.prices, + entitlements: newProduct.entitlements, + features: attachParams.features, + }), + features: attachParams.features, + anchorToUnix: cycleWillReset + ? undefined + : stripeSubs[0].current_period_end * 1000, + now, + freeTrial: attachParams.freeTrial, + }); - const proratedAmount = calculateProrationAmount({ - periodEnd: stripeSubs[0].current_period_end * 1000, - periodStart: now, - now, - amount, - }); - - console.log("Item:", description); - console.log("Amount: ", amount); - console.log("--------------------------------"); + if (branch == AttachBranch.UpdatePrepaidQuantity) { + items = items.filter((item) => item.usage_model == UsageModel.Prepaid); + nextCycleItems = nextCycleItems.filter( + (item) => item.usage_model == UsageModel.Prepaid, + ); } + + return { + currency: attachParams.org.default_currency, + due_today: { + line_items: items, + total: dueTodayAmt, + }, + due_next_cycle: { + line_items: nextCycleItems, + due_at: nextCycleAt.next_cycle_at, + }, + + options, + }; }; diff --git a/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts index 446855386..b675d010b 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts @@ -8,9 +8,12 @@ import { ExtendedResponse } from "@/utils/models/Request.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { AttachFunction } from "../models/AttachBranch.js"; import { getNewProductPreview } from "./getNewProductPreview.js"; -import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; -import { CheckProductPreview } from "@autumn/shared"; import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js"; +import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; +import { getUpdateEntsPreview } from "./getUpdateEntsPreview.js"; +import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js"; +import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js"; +import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; export const handleAttachPreview = (req: any, res: any) => routeHandler({ @@ -30,6 +33,7 @@ export const handleAttachPreview = (req: any, res: any) => req, attachBody, attachParams, + fromPreview: true, }); const { flags, config } = await getAttachConfig({ @@ -48,31 +52,57 @@ export const handleAttachPreview = (req: any, res: any) => console.log(`Branch: ${branch}, Function: ${func}`); - const { org, features } = attachParams; - const product = attachParamsToProduct({ - attachParams, - }); + const { stripeCli, stripeCus } = attachParams; + const now = await getStripeNow({ stripeCli, stripeCus }); let preview: any = null; + if (func == AttachFunction.UpdateEnts) { + preview = await getUpdateEntsPreview({ + req, + attachParams, + now, + }); + } + if ( func == AttachFunction.AddProduct || func == AttachFunction.CreateCheckout ) { preview = await getNewProductPreview({ - org, - product, - features, + attachParams, + now, }); } - if (func == AttachFunction.UpdateProduct) { + if (func == AttachFunction.ScheduleProduct) { + preview = await getDowngradeProductPreview({ + attachParams, + now, + }); + } + + if ( + func == AttachFunction.UpdateProduct || + func == AttachFunction.UpdatePrepaidQuantity + ) { preview = await getUpgradeProductPreview({ req, attachParams, + branch, + now, }); } - res.status(200).json({ preview }); + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + res.status(200).json({ + branch, + ...preview, + current_product: curMainProduct + ? cusProductToProduct({ + cusProduct: curMainProduct, + }) + : null, + }); }, }); diff --git a/server/src/internal/customers/attach/models/AttachBranch.ts b/server/src/internal/customers/attach/models/AttachBranch.ts index 066209b96..60c616f81 100644 --- a/server/src/internal/customers/attach/models/AttachBranch.ts +++ b/server/src/internal/customers/attach/models/AttachBranch.ts @@ -1,23 +1,24 @@ -export enum AttachBranch { - MultiProduct = "multi_product", - OneOff = "one_off", - New = "new", - AddOn = "add_on", +// export enum AttachBranch { +// MultiProduct = "multi_product", - // Same product - NewVersion = "new_version", // - SameCustomEnts = "same_custom_ents", - SameCustom = "same_custom", +// OneOff = "one_off", - UpdatePrepaidQuantity = "update_prepaid_quantity", - Renew = "renew", +// New = "new", +// AddOn = "add_on", - // Handle upgrades / downgrades - MainIsFree = "main_is_free", - MainIsTrial = "main_is_trial", - Upgrade = "upgrade", - Downgrade = "downgrade", -} +// // Same product +// NewVersion = "new_version", +// SameCustomEnts = "same_custom_ents", +// SameCustom = "same_custom", +// UpdatePrepaidQuantity = "update_prepaid_quantity", +// Renew = "renew", + +// // Handle upgrades / downgrades +// MainIsFree = "main_is_free", +// MainIsTrial = "main_is_trial", +// Upgrade = "upgrade", +// Downgrade = "downgrade", +// } export enum AttachFunction { CreateCheckout = "create_checkout", diff --git a/server/src/internal/customers/attach/models/AttachFlags.ts b/server/src/internal/customers/attach/models/AttachFlags.ts index 86ff873b5..3cee9de1a 100644 --- a/server/src/internal/customers/attach/models/AttachFlags.ts +++ b/server/src/internal/customers/attach/models/AttachFlags.ts @@ -1,4 +1,4 @@ -import { AttachBranch } from "./AttachBranch.js"; +import { AttachBranch } from "@autumn/shared"; export enum ProrationBehavior { Immediately = "immediately", diff --git a/server/src/internal/customers/change-product/billRemainingUsages.ts b/server/src/internal/customers/change-product/billRemainingUsages.ts index f50382f61..b92bff0ec 100644 --- a/server/src/internal/customers/change-product/billRemainingUsages.ts +++ b/server/src/internal/customers/change-product/billRemainingUsages.ts @@ -37,7 +37,6 @@ const addUsageToNextInvoice = async ({ org, logger, attachParams, - curCusProduct, }: { db: DrizzleCli; intervalToInvoiceItems: any; @@ -45,9 +44,7 @@ const addUsageToNextInvoice = async ({ customer: any; org: any; logger: any; - attachParams: AttachParams; - curCusProduct: FullCusProduct; }) => { for (const interval in intervalToInvoiceItems) { const itemsToInvoice = intervalToInvoiceItems[interval]; @@ -63,7 +60,7 @@ const addUsageToNextInvoice = async ({ }); for (const item of itemsToInvoice) { - const amount = getPriceForOverage(item.price, item.overage); + const { amount, description } = item; logger.info( ` feature: ${item.feature.id}, overage: ${item.overage}, amount: ${amount}`, @@ -71,11 +68,6 @@ const addUsageToNextInvoice = async ({ let relatedSub = intervalToSub[interval]; if (!relatedSub) { - logger.error( - `No sub found for interval: ${interval}, for feature: ${item.feature.id}`, - ); - - // Invoice immediately? continue; } @@ -83,9 +75,7 @@ const addUsageToNextInvoice = async ({ let invoiceItem = { customer: customer.processor.id, currency: org.default_currency, - description: `${curCusProduct.product.name} - ${ - item.feature.name - } x ${Math.round(item.usage)}`, + description, price_data: { product: (item.price.config! as UsagePriceConfig).stripe_product_id!, unit_amount: Math.round(amount * 100), @@ -190,14 +180,10 @@ const invoiceForUsageImmediately = async ({ let autumnInvoiceItems: InvoiceItem[] = []; for (const item of invoiceItems) { - const amount = getPriceForOverage(item.price, item.overage); + // const amount = getPriceForOverage(item.price, item.overage); + const { amount, description } = item; let config = item.price.config! as UsagePriceConfig; - - // TO TEST let stripePrice = await stripeCli.prices.retrieve(config.stripe_price_id!); - let description = `${curCusProduct.product.name} - ${ - item.feature.name - } x ${Math.round(item.usage)}`; // need to standardize... logger.info( `🌟🌟🌟 (Bill remaining) created invoice item: ${description} -- ${amount}`, @@ -207,7 +193,7 @@ const invoiceForUsageImmediately = async ({ customer: customer.processor.id, invoice: invoice.id, currency: org.default_currency, - description: description, + description, price_data: { product: stripePrice.product as string, unit_amount: Math.round(amount * 100), @@ -308,16 +294,16 @@ export const billForRemainingUsages = async ({ attachParams, curCusProduct, newSubs, - config, shouldPreview = false, + billImmediately = false, }: { db: DrizzleCli; logger: any; attachParams: AttachParams; curCusProduct: FullCusProduct; newSubs: Stripe.Subscription[]; - config: AttachConfig; shouldPreview?: boolean; + billImmediately?: boolean; }) => { const { customer_prices, customer_entitlements } = curCusProduct; const { customer, org } = attachParams; @@ -346,7 +332,8 @@ export const billForRemainingUsages = async ({ const billingType = getBillingType(config); if (billingType !== BillingType.UsageInArrear) continue; - const { usage, overage, roundedUsage } = getCusPriceUsage({ + + const { usage, overage, description, amount } = getCusPriceUsage({ cusPrice: cp, cusProduct: curCusProduct, logger, @@ -369,6 +356,9 @@ export const billForRemainingUsages = async ({ intervalToInvoiceItems[interval].push({ overage, usage, + description, + amount, + feature: relatedCusEnt?.entitlement.feature, price: cp.price, relatedCusEnt, @@ -384,7 +374,7 @@ export const billForRemainingUsages = async ({ }); } - if (config.proration == ProrationBehavior.Immediately) { + if (billImmediately) { await invoiceForUsageImmediately({ db, intervalToInvoiceItems, @@ -404,7 +394,6 @@ export const billForRemainingUsages = async ({ org, logger, attachParams, - curCusProduct, }); } }; diff --git a/server/src/internal/customers/change-product/handleChangeProduct.ts b/server/src/internal/customers/change-product/handleChangeProduct.ts index 3cb5cc013..e4be5c481 100644 --- a/server/src/internal/customers/change-product/handleChangeProduct.ts +++ b/server/src/internal/customers/change-product/handleChangeProduct.ts @@ -1,90 +1,89 @@ -import { createStripeCli } from "@/external/stripe/utils.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { isProductUpgrade } from "@/internal/products/productUtils.js"; -import { ErrCode, FullCusProduct } from "@autumn/shared"; -import { AttachParams } from "../cusProducts/AttachParams.js"; -import { handleUpgrade } from "./handleUpgrade.js"; -import { StatusCodes } from "http-status-codes"; -import { handleDowngrade } from "./handleDowngrade.js"; -import { getPricesForCusProduct } from "./scheduleUtils.js"; -import { cancelScheduledProductIfExists } from "./changeProductUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; +// import { createStripeCli } from "@/external/stripe/utils.js"; +// import { ProductService } from "@/internal/products/ProductService.js"; +// import { isProductUpgrade } from "@/internal/products/productUtils.js"; +// import { ErrCode, FullCusProduct } from "@autumn/shared"; +// import { AttachParams } from "../cusProducts/AttachParams.js"; +// import { handleUpgrade } from "./handleUpgrade.js"; +// import { StatusCodes } from "http-status-codes"; +// import { getPricesForCusProduct } from "./scheduleUtils.js"; +// import { cancelScheduledProductIfExists } from "./changeProductUtils.js"; +// import RecaseError from "@/utils/errorUtils.js"; -export const handleChangeProduct = async ({ - req, - res, - attachParams, - curCusProduct, -}: { - req: any; - res: any; - attachParams: AttachParams; - curCusProduct: FullCusProduct; -}) => { - // Get subscription - const curProduct = curCusProduct.product; - const { org, customer, products } = attachParams; +// export const handleChangeProduct = async ({ +// req, +// res, +// attachParams, +// curCusProduct, +// }: { +// req: any; +// res: any; +// attachParams: AttachParams; +// curCusProduct: FullCusProduct; +// }) => { +// // Get subscription +// const curProduct = curCusProduct.product; +// const { org, customer, products } = attachParams; - // Can only upgrade once for now - if (products.length > 1) { - throw new RecaseError({ - message: `Can't handle upgrade / downgrade for multiple products`, - code: ErrCode.UpgradeFailed, - statusCode: StatusCodes.NOT_IMPLEMENTED, - }); - } +// // Can only upgrade once for now +// if (products.length > 1) { +// throw new RecaseError({ +// message: `Can't handle upgrade / downgrade for multiple products`, +// code: ErrCode.UpgradeFailed, +// statusCode: StatusCodes.NOT_IMPLEMENTED, +// }); +// } - const stripeCli = createStripeCli({ - org: attachParams.org, - env: attachParams.customer.env, - }); +// const stripeCli = createStripeCli({ +// org: attachParams.org, +// env: attachParams.customer.env, +// }); - const logger = req.logtail; +// const logger = req.logtail; - // 0. Cancel any scheduled products - await cancelScheduledProductIfExists({ - req, - org: attachParams.org, - stripeCli, - attachParams, - curFullProduct: curCusProduct.product as any, - logger, - }); +// // 0. Cancel any scheduled products +// await cancelScheduledProductIfExists({ +// req, +// org: attachParams.org, +// stripeCli, +// attachParams, +// curFullProduct: curCusProduct.product as any, +// logger, +// }); - const curFullProduct = await ProductService.getFull({ - db: req.db, - idOrInternalId: curProduct.id, - orgId: org.id, - env: customer.env, - }); +// const curFullProduct = await ProductService.getFull({ +// db: req.db, +// idOrInternalId: curProduct.id, +// orgId: org.id, +// env: customer.env, +// }); - let curPrices = getPricesForCusProduct({ - cusProduct: curCusProduct!, - }); - let newPrices = attachParams.prices; +// let curPrices = getPricesForCusProduct({ +// cusProduct: curCusProduct!, +// }); +// let newPrices = attachParams.prices; - const isUpgrade = - attachParams.invoiceOnly || - isProductUpgrade({ - prices1: curPrices, - prices2: newPrices, - }); +// const isUpgrade = +// attachParams.invoiceOnly || +// isProductUpgrade({ +// prices1: curPrices, +// prices2: newPrices, +// }); - if (!isUpgrade) { - await handleDowngrade({ - req, - res, - attachParams, - curCusProduct, - }); - return; - } else { - await handleUpgrade({ - req, - res, - attachParams, - curCusProduct, - curFullProduct, - }); - } -}; +// if (!isUpgrade) { +// await handleDowngrade({ +// req, +// res, +// attachParams, +// curCusProduct, +// }); +// return; +// } else { +// await handleUpgrade({ +// req, +// res, +// attachParams, +// curCusProduct, +// curFullProduct, +// }); +// } +// }; diff --git a/server/src/internal/customers/change-product/handleDowngrade.ts b/server/src/internal/customers/change-product/handleDowngrade.ts deleted file mode 100644 index f4349fd54..000000000 --- a/server/src/internal/customers/change-product/handleDowngrade.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { CusProductService } from "../cusProducts/CusProductService.js"; -import Stripe from "stripe"; -import { - AttachParams, - AttachResultSchema, -} from "../cusProducts/AttachParams.js"; -import { - getStripeSchedules, - getStripeSubs, -} from "@/external/stripe/stripeSubUtils.js"; -import { createStripeCli } from "@/external/stripe/utils.js"; -import { APIVersion, AttachScenario, FullCusProduct } from "@autumn/shared"; -import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; -import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; -import { BillingInterval } from "@autumn/shared"; -import { updateScheduledSubWithNewItems } from "./scheduleUtils/updateScheduleWithNewItems.js"; -import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; -import { - attachToInsertParams, - isFreeProduct, -} from "@/internal/products/productUtils.js"; - -import { generateId } from "@/utils/genUtils.js"; -import { ItemSet } from "@/utils/models/ItemSet.js"; -import { SubService } from "@/internal/subscriptions/SubService.js"; - -import { SuccessCode } from "@autumn/shared"; -import { cancelCurSubs } from "./handleDowngrade/cancelCurSubs.js"; -import { getScheduleIdsFromCusProducts } from "./scheduleUtils.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; - -const scheduleStripeSubscription = async ({ - db, - attachParams, - stripeCli, - itemSet, - endOfBillingPeriod, -}: { - db: DrizzleCli; - attachParams: AttachParams; - stripeCli: Stripe; - itemSet: ItemSet; - endOfBillingPeriod: number; -}) => { - const { org, customer } = attachParams; - const { items, prices, subMeta } = itemSet; - - const paymentMethod = await getCusPaymentMethod({ - stripeCli, - stripeId: customer.processor.id, - }); - - let subItems = items.filter( - (item: any, index: number) => - index >= prices.length || - prices[index].config!.interval !== BillingInterval.OneOff, - ); - let oneOffItems = items.filter( - (item: any, index: number) => - index < prices.length && - prices[index].config!.interval === BillingInterval.OneOff, - ); - - const newSubscriptionSchedule = await stripeCli.subscriptionSchedules.create({ - customer: customer.processor.id, - start_date: endOfBillingPeriod, - - phases: [ - { - items: subItems, - default_payment_method: paymentMethod?.id, - add_invoice_items: oneOffItems, - }, - ], - }); - - await SubService.createSub({ - db, - sub: { - id: generateId("sub"), - stripe_id: null, - stripe_schedule_id: newSubscriptionSchedule.id, - created_at: Date.now(), - usage_features: itemSet.usageFeatures, - org_id: org.id, - env: customer.env, - current_period_start: null, - current_period_end: null, - }, - }); - - return newSubscriptionSchedule.id; -}; - -export const getCusProductsWithStripeSubIds = async ({ - cusProducts, - stripeSubId, - curCusProductId, -}: { - cusProducts: FullCusProduct[]; - stripeSubId: string; - curCusProductId?: string; -}) => { - return cusProducts.filter( - (cusProduct) => - cusProduct.subscription_ids?.includes(stripeSubId) && - cusProduct.id !== curCusProductId, - ); -}; - -export const handleDowngrade = async ({ - req, - res, - attachParams, - curCusProduct, -}: { - req: any; - res: any; - attachParams: AttachParams; - curCusProduct: FullCusProduct; -}) => { - const logger = req.logtail; - let product = attachParams.products[0]; - const stripeCli = createStripeCli({ - org: attachParams.org, - env: attachParams.customer.env, - }); - logger.info( - `Handling downgrade from ${curCusProduct.product.name} to ${product.name}`, - ); - - const curSubscriptions = await getStripeSubs({ - stripeCli, - subIds: curCusProduct.subscription_ids!, - }); - - const latestPeriodEnd = curSubscriptions[0].current_period_end; - - // 1. Cancel all current subscriptions - logger.info("1. Cancelling current subscription (at period end)"); - const intervalToOtherSubs = await cancelCurSubs({ - curSubs: curSubscriptions, - stripeCli, - curCusProduct, - }); - - // 3. Schedule new subscription IF new product is not free... - logger.info("2. Schedule new subscription"); - let oldScheduledIds: string[] = getScheduleIdsFromCusProducts({ - cusProducts: [curCusProduct, attachParams.curScheduledProduct], - }); - - let schedules: any[] = []; - - if (oldScheduledIds.length > 0) { - schedules = await getStripeSchedules({ - stripeCli, - scheduleIds: oldScheduledIds, - }); - } - - // 4. Create new subscription schedule - const itemSets: any[] = await getStripeSubItems({ - attachParams, - isCheckout: false, - }); - - let scheduledIds: string[] = []; - - for (const itemSet of itemSets) { - let scheduleObj = schedules.find( - (schedule) => schedule.interval === itemSet.interval, - ); - - if (scheduleObj) { - await updateScheduledSubWithNewItems({ - scheduleObj, - newItems: itemSet.items, - stripeCli, - cusProducts: [curCusProduct, attachParams.curScheduledProduct], - itemSet: itemSet, - db: req.db, - org: attachParams.org, - env: attachParams.customer.env, - }); - scheduledIds.push(scheduleObj.schedule.id); - } else { - const otherSubObj = intervalToOtherSubs[itemSet.interval]; - - let otherSub = otherSubObj?.otherSub || null; - let otherSubItems = otherSubObj?.otherSubItems || []; - - let otherCusProducts = otherSub - ? await getCusProductsWithStripeSubIds({ - cusProducts: attachParams.cusProducts!, - stripeSubId: otherSub.id, - }) - : []; - - // If there is other sub items - itemSet.items.push( - ...otherSubItems.map((sub: any) => ({ - price: sub.price.id, - quantity: sub.quantity, - })), - ); - - let scheduleId = await scheduleStripeSubscription({ - db: req.db, - attachParams, - stripeCli, - itemSet, - endOfBillingPeriod: latestPeriodEnd, - }); - scheduledIds.push(scheduleId); - - if (otherCusProducts.length > 0) { - for (const otherCusProduct of otherCusProducts) { - let newScheduledIds = [ - ...(otherCusProduct.scheduled_ids || []), - scheduleId, - ]; - await CusProductService.update({ - db: req.db, - cusProductId: otherCusProduct.id, - updates: { scheduled_ids: newScheduledIds }, - }); - } - } - } - } - - // Remove scheduled ids from curCusProduct - await CusProductService.update({ - db: req.db, - cusProductId: curCusProduct.id, - updates: { - scheduled_ids: curCusProduct.scheduled_ids?.filter( - (id) => !scheduledIds.includes(id), - ), - }, - }); - - // 4. Update cus product - logger.info("3. Inserting new full cus product (starts at period end)"); - const newProductFree = isFreeProduct(attachParams.prices); - await createFullCusProduct({ - db: req.db, - attachParams: attachToInsertParams(attachParams, product), - startsAt: latestPeriodEnd * 1000, - subscriptionScheduleIds: scheduledIds, - nextResetAt: latestPeriodEnd * 1000, - disableFreeTrial: true, - isDowngrade: true, - scenario: newProductFree ? AttachScenario.Cancel : AttachScenario.Downgrade, - }); - - // 5. Updating current cus product canceled_at... - await CusProductService.update({ - db: req.db, - cusProductId: curCusProduct.id, - updates: { - canceled_at: latestPeriodEnd * 1000, - }, - }); - - let apiVersion = attachParams.apiVersion || APIVersion.v1; - if (apiVersion >= APIVersion.v1_1) { - res.status(200).json( - AttachResultSchema.parse({ - code: SuccessCode.DowngradeScheduled, - message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`, - product_ids: [product.id], - customer_id: - attachParams.customer.id || attachParams.customer.internal_id, - }), - ); - } else { - res.status(200).json({ - success: true, - }); - } -}; diff --git a/server/src/internal/customers/change-product/handleUpgrade.ts b/server/src/internal/customers/change-product/handleUpgrade.ts index cd2c7787e..c6db3208f 100644 --- a/server/src/internal/customers/change-product/handleUpgrade.ts +++ b/server/src/internal/customers/change-product/handleUpgrade.ts @@ -119,7 +119,10 @@ export const handleStripeSubUpdate = async ({ // 2. Add trial to new subscription? let trialEnd; if (!disableFreeTrial) { - trialEnd = freeTrialToStripeTimestamp(attachParams.freeTrial); + trialEnd = freeTrialToStripeTimestamp({ + freeTrial: attachParams.freeTrial, + now: attachParams.now, + }); } // 3. Update current subscription @@ -297,6 +300,7 @@ const handleOnlyEntsChanged = async ({ disableFreeTrial: false, keepResetIntervals: true, carryExistingUsages, + logger, }); logger.info("✅ Successfully updated entitlements for product"); @@ -502,6 +506,7 @@ export const handleUpgrade = async ({ carryExistingUsages, carryOverTrial: true, scenario: AttachScenario.Upgrade, + logger, }); // Create invoices diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 8ae2f6ad4..08a4793d4 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -19,6 +19,8 @@ import { z } from "zod"; export type AttachParams = { stripeCli: Stripe; + stripeCus?: Stripe.Customer; + now?: number; paymentMethod: Stripe.PaymentMethod | null | undefined; org: Organization; diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index d1a35c55b..db878a8f4 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -263,7 +263,7 @@ export class CusProductService { }) { // sql`${customerProducts.subscription_ids} @> ${sql`ARRAY[${stripeSubId}]`}`, let data = await db.query.customerProducts.findMany({ - where: (table, { and, or, eq, sql, inArray }) => + where: (table, { and, or, inArray }) => and( or(arrayContains(customerProducts.subscription_ids, [stripeSubId])), inStatuses ? inArray(customerProducts.status, inStatuses) : undefined, diff --git a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts index 2b96871e4..9ae3f3e5c 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts @@ -3,6 +3,7 @@ import { FullCusProduct, FullCustomerEntitlement, FullCustomerPrice, + getFeatureInvoiceDescription, Price, UsagePriceConfig, } from "@autumn/shared"; @@ -91,10 +92,16 @@ export const getCusPriceUsage = ({ const amount = getPriceForOverage(cusPrice.price, -totalNegativeBalance); + const description = getFeatureInvoiceDescription({ + feature: cusEnt.entitlement.feature, + usage, + }); + return { usage, // total usage overage: -totalNegativeBalance, // usage that's past the allowance roundedUsage: roundedQuantity, // usage rounded to the nearest billing unit amount, + description, }; }; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index e6d4b5bad..1c21eaad1 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -122,6 +122,7 @@ export const activateDefaultProduct = async ({ org, env, curCusProduct, + logger, }: { db: DrizzleCli; productGroup: string; @@ -129,6 +130,7 @@ export const activateDefaultProduct = async ({ org: Organization; env: AppEnv; curCusProduct?: FullCusProduct; + logger: any; }) => { // 1. Expire current product const defaultProducts = await ProductService.listDefault({ @@ -165,6 +167,7 @@ export const activateDefaultProduct = async ({ features: [], }, scenario: AttachScenario.New, + logger, }); // console.log(` ✅ activated default product: ${defaultProd.group}`); @@ -176,11 +179,13 @@ export const expireAndActivate = async ({ env, cusProduct, org, + logger, }: { db: DrizzleCli; env: AppEnv; cusProduct: FullCusProduct; org: Organization; + logger: any; }) => { // 1. Expire current product await CusProductService.update({ @@ -195,6 +200,7 @@ export const expireAndActivate = async ({ customer: cusProduct.customer!, org, env, + logger, }); }; diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index e38e6912c..f13e1673b 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -212,6 +212,7 @@ export const createNewCustomer = async ({ ? getNextStartOfMonthUnix(BillingInterval.Month) : undefined, scenario: AttachScenario.New, + logger, }); } diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index f440d3d18..0b24a8438 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -29,6 +29,7 @@ import { CusReadService } from "./CusReadService.js"; import { StatusCodes } from "http-status-codes"; import { getAttachPreview } from "../api/entitled/handlers/getAttachPreview.js"; import { cusProductToProduct } from "./cusProducts/cusProductUtils/convertCusProduct.js"; +import { createOrgResponse } from "../orgs/orgUtils.js"; export const cusRouter = Router(); @@ -347,6 +348,7 @@ cusRouter.get( features, numVersions, entities: customer.entities, + org: createOrgResponse(org), }); } catch (error) { handleFrontendReqError({ diff --git a/server/src/internal/customers/previews/getUpgradePreview.ts b/server/src/internal/customers/previews/getUpgradePreview.ts index 455605ddf..c3f19cebd 100644 --- a/server/src/internal/customers/previews/getUpgradePreview.ts +++ b/server/src/internal/customers/previews/getUpgradePreview.ts @@ -7,7 +7,6 @@ import { AttachScenario, BillingType, CheckProductPreview, - Customer, Feature, FullCusProduct, FullCustomer, diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 51f550696..fd810fcb5 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -8,6 +8,8 @@ import { FullCustomerPrice, FullProduct, getFeatureName, + getFeatureNameWithCapital, + Organization, Price, UsagePriceConfig, } from "@autumn/shared"; @@ -21,6 +23,7 @@ import { getRelatedCusEnt, } from "../customers/cusProducts/cusPrices/cusPriceUtils.js"; import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js"; +import { formatAmount } from "@/utils/formatUtils.js"; const getSingularAndPlural = (feature: Feature) => { const singular = getFeatureName({ @@ -59,48 +62,57 @@ export const formatPrepaidPrice = ({ } }; -export const formatFixedPrice = ({ price }: { price: Price }) => { - const config = price.config as FixedPriceConfig; - if (config.interval == BillingInterval.OneOff) { - return `$${config.amount.toFixed(2)}`; - } else { - return `$${config.amount.toFixed(2)} / ${config.interval}`; - } -}; - -export const formatUsageInArrear = ({ +export const formatFixedPrice = ({ + org, price, - cusProduct, - logger, }: { + org: Organization; price: Price; - cusProduct: FullCusProduct; - logger: any; }) => { - const cusPrice = cusProduct.customer_prices.find( - (cp) => cp.price.id == price.id, - ); + const config = price.config as FixedPriceConfig; + const amount = formatAmount({ org, amount: config.amount }); - const { usage, overage, roundedUsage } = getCusPriceUsage({ - cusPrice: cusPrice!, - cusProduct, - logger, - }); - - const cusEnt = getRelatedCusEnt({ - cusPrice: cusPrice!, - cusEnts: cusProduct.customer_entitlements, - })!; - - const { singular, plural } = getSingularAndPlural(cusEnt.entitlement.feature); - - if (usage == 1) { - return `${usage} x ${singular}`; + if (config.interval == BillingInterval.OneOff) { + return `${amount}`; } else { - return `${usage} x ${plural}`; + return `${amount} / ${config.interval}`; } }; +// export const formatUsageInArrear = ({ +// price, +// cusProduct, +// logger, +// }: { +// price: Price; +// feature: Feature; +// cusProduct: FullCusProduct; +// logger: any; +// }) => { +// // const cusPrice = cusProduct.customer_prices.find( +// // (cp) => cp.price.id == price.id, +// // ); + +// // const { usage, overage, roundedUsage } = getCusPriceUsage({ +// // cusPrice: cusPrice!, +// // cusProduct, +// // logger, +// // }); + +// // const cusEnt = getRelatedCusEnt({ +// // cusPrice: cusPrice!, +// // cusEnts: cusProduct.customer_entitlements, +// // })!; + +// const { singular, plural } = getSingularAndPlural(cusEnt.entitlement.feature); + +// if (usage == 1) { +// return `${usage} x ${singular}`; +// } else { +// return `${usage} x ${plural}`; +// } +// }; + export const formatInArrearProrated = ({ price, ents, @@ -122,6 +134,7 @@ export const formatInArrearProrated = ({ }; export const priceToInvoiceDescription = ({ + org, price, cusProduct, quantity, @@ -129,6 +142,7 @@ export const priceToInvoiceDescription = ({ }: { price: Price; cusProduct: FullCusProduct; + org?: Organization; quantity?: number; logger: any; }) => { @@ -150,28 +164,28 @@ export const priceToInvoiceDescription = ({ billingType == BillingType.FixedCycle || billingType == BillingType.OneOff ) { - description = formatFixedPrice({ price }); + description = formatFixedPrice({ org: org!, price }); } if (billingType == BillingType.InArrearProrated) { description = formatInArrearProrated({ price, ents, quantity }); } - if (billingType == BillingType.UsageInArrear) { - description = formatUsageInArrear({ price, cusProduct, logger }); - } - return `${productName} - ${description}`; }; export const newPriceToInvoiceDescription = ({ + org, price, product, quantity, + withProductPrefix = true, }: { + org: Organization; price: Price; product: FullProduct; quantity?: number; + withProductPrefix?: boolean; }) => { const ents = product.entitlements; @@ -182,16 +196,17 @@ export const newPriceToInvoiceDescription = ({ billingType == BillingType.FixedCycle || billingType == BillingType.OneOff ) { - description = formatFixedPrice({ price }); + description = formatFixedPrice({ org, price }); } if (billingType == BillingType.InArrearProrated) { description = formatInArrearProrated({ price, ents, quantity }); } - if (billingType == BillingType.UsageInAdvance) { - return null; + if (billingType == BillingType.UsageInArrear) { + const ent = getPriceEntitlement(price, ents); + description = getFeatureNameWithCapital({ feature: ent.feature }); } - return `${product.name} - ${description}`; + return `${withProductPrefix ? `${product.name} - ` : ""}${description}`; }; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts new file mode 100644 index 000000000..d9e1a8f84 --- /dev/null +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -0,0 +1,117 @@ +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 { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import Stripe from "stripe"; +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 { BillingType, PreviewItem, UsageModel } from "@autumn/shared"; +import { priceToInvoiceDescription } from "../invoiceFormatUtils.js"; +import { formatUnixToDate, formatUnixToDateTime } from "@/utils/genUtils.js"; +import { formatAmount } from "@/utils/formatUtils.js"; +import { getProration } from "./getItemsForNewProduct.js"; +import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; +import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; + +export const getItemsForCurProduct = ({ + stripeSubs, + attachParams, + now, + logger, +}: { + stripeSubs: Stripe.Subscription[]; + attachParams: AttachParams; + now: number; + logger: any; +}) => { + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + const curCusProduct = curMainProduct!; + const curPrices = cusProductToPrices({ cusProduct: curCusProduct }); + + const items: PreviewItem[] = []; + for (const sub of stripeSubs) { + for (const item of sub.items.data) { + const price = findPriceInStripeItems({ + prices: curPrices, + subItem: item, + }); + + if (!price) continue; + const billingType = getBillingType(price.config); + if (billingType == BillingType.UsageInArrear) continue; + + const totalAmountCents = getSubItemAmount({ subItem: item }); + const totalAmount = new Decimal(totalAmountCents).div(100).toNumber(); + + if (totalAmount == 0) continue; + + const periodEnd = sub.current_period_end * 1000; + + if (now < periodEnd) { + const finalProration = getProration({ + now, + interval: price.config.interval!, + anchorToUnix: sub.current_period_end * 1000, + })!; + + const proratedAmount = -calculateProrationAmount({ + periodEnd: finalProration?.end, + periodStart: finalProration?.start, + now, + amount: totalAmount, + }); + + let description = priceToInvoiceDescription({ + price, + org: attachParams.org, + cusProduct: curCusProduct, + quantity: item.quantity, + logger, + }); + + description = `Unused ${description} (from ${formatUnixToDate(now)})`; + + // console.log("Item:", description); + // console.log("Period ends:", formatUnixToDateTime(periodEnd)); + // console.log("Prorated amount: ", proratedAmount); + // console.log("--------------------------------"); + items.push({ + price: formatAmount({ + org: attachParams.org, + amount: proratedAmount, + }), + description, + amount: proratedAmount, + usage_model: priceToUsageModel(price), + }); + } + } + } + + for (const price of curPrices) { + let billingType = getBillingType(price.config); + + if (billingType == BillingType.UsageInArrear) { + const { amount, description } = getCusPriceUsage({ + price, + cusProduct: curCusProduct, + logger, + }); + + if (!amount) continue; + + items.push({ + price: formatAmount({ + org: attachParams.org, + amount, + }), + description: `${curCusProduct.product.name} - ${description}`, + amount, + }); + } + } + + return items; +}; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts new file mode 100644 index 000000000..ef948335a --- /dev/null +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -0,0 +1,240 @@ +import { + BillingType, + EntitlementWithFeature, + FullProduct, + Organization, + UsagePriceConfig, + PreviewItem, + Price, + Feature, + BillingInterval, + FreeTrial, +} from "@autumn/shared"; +import { AttachParams } from "../../customers/cusProducts/AttachParams.js"; +import { + getBillingType, + getPriceForOverage, +} from "../../products/prices/priceUtils.js"; +import { getPriceEntitlement } from "../../products/prices/priceUtils.js"; +import { isFixedPrice } from "../../products/prices/priceUtils/usagePriceUtils.js"; +import { getExistingUsageFromCusProducts } from "../../customers/cusProducts/cusEnts/cusEntUtils.js"; +import { Decimal } from "decimal.js"; +import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js"; +import { calculateProrationAmount } from "../prorationUtils.js"; +import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; +import { toProductItem } from "../../products/product-items/mapToItem.js"; +import { formatAmount } from "@/utils/formatUtils.js"; +import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; +import { + getAlignedIntervalUnix, + getNextStartOfMonthUnix, + subtractBillingIntervalUnix, +} from "../../products/prices/billingIntervalUtils.js"; +import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; + +const getDefaultPriceStr = ({ + org, + price, + ent, + features, +}: { + org: Organization; + price: Price; + ent: EntitlementWithFeature; + features: Feature[]; +}) => { + const item = toProductItem({ + ent: ent!, + price, + }); + + const priceText = getPricecnPrice({ + org, + items: [item], + features, + isMainPrice: true, + }); + + return `${priceText.primaryText} ${priceText.secondaryText}`; +}; + +export const getProration = ({ + proration, + anchorToUnix, + now, + interval, +}: { + proration?: { + start: number; + end: number; + }; + anchorToUnix?: number; + interval: BillingInterval; + now: number; +}) => { + if (!proration && !anchorToUnix) return undefined; + + if (proration) { + return proration; + } + + let end = getAlignedIntervalUnix({ + alignWithUnix: anchorToUnix!, + interval, + now, + alwaysReturn: true, + }); + let start = subtractBillingIntervalUnix(end!, interval); + + return { + start, + end: end!, + }; +}; + +export const getItemsForNewProduct = ({ + newProduct, + attachParams, + now, + proration, + interval, + anchorToUnix, + freeTrial, +}: { + newProduct: FullProduct; + attachParams: AttachParams; + now?: number; + proration?: { + start: number; + end: number; + }; + interval?: BillingInterval; + anchorToUnix?: number; + freeTrial?: FreeTrial | null; +}) => { + const { org, features } = attachParams; + + now = now || Date.now(); + + const items: PreviewItem[] = []; + + for (const price of newProduct.prices) { + const ent = getPriceEntitlement(price, newProduct.entitlements); + const billingType = getBillingType(price.config); + + if (interval && price.config.interval !== interval) continue; + + const finalProration = getProration({ + proration, + anchorToUnix, + now, + interval: price.config.interval!, + }); + + if (isFixedPrice({ price })) { + const amount = finalProration + ? calculateProrationAmount({ + periodEnd: finalProration.end, + periodStart: finalProration.start, + now, + amount: getPriceForOverage(price), + }) + : getPriceForOverage(price, 0); + + let description = newPriceToInvoiceDescription({ + org, + price, + product: newProduct, + }); + + if (proration) { + description = `${description} (from ${formatUnixToDate(now)})`; + } + + items.push({ + // price: formatAmount({ org, amount }), + price: "", + description, + amount, + usage_model: priceToUsageModel(price), + }); + continue; + } + + if (billingType == BillingType.UsageInAdvance) continue; + + if (billingType == BillingType.UsageInArrear) { + items.push({ + price: getDefaultPriceStr({ org, price, ent, features }), + description: newPriceToInvoiceDescription({ + org, + price, + product: newProduct, + }), + usage_model: priceToUsageModel(price), + }); + continue; + } + + const usage = getExistingUsageFromCusProducts({ + entitlement: ent, + cusProducts: attachParams.cusProducts, + entities: attachParams.entities, + carryExistingUsages: undefined, + internalEntityId: attachParams.internalEntityId, + }); + + let description = newPriceToInvoiceDescription({ + org, + price, + product: newProduct, + quantity: usage, + }); + + if (usage == 0) { + items.push({ + price: getDefaultPriceStr({ org, price, ent, features }), + description, + usage_model: priceToUsageModel(price), + }); + } else { + const overage = new Decimal(usage).sub(ent.allowance!).toNumber(); + const amount = finalProration + ? calculateProrationAmount({ + periodEnd: finalProration.end, + periodStart: finalProration.start, + now, + amount: getPriceForOverage(price, overage), + }) + : getPriceForOverage(price, overage); + + if (proration) { + description = `${description} (from ${formatUnixToDate(now)})`; + } + + items.push({ + price: "", + description, + amount, + usage_model: priceToUsageModel(price), + }); + } + + // // const finalAmount = cycleWillReset ? amount : proratedAmount; + + // console.log("Item:", description); + // console.log("Amount: ", amount); + // console.log("--------------------------------"); + } + + for (const item of items) { + if (item.amount && freeTrial) { + item.amount = 0; + } + if (notNullish(item.amount)) { + item.price = formatAmount({ org, amount: item.amount! }); + } + } + + return items; +}; diff --git a/server/src/internal/invoices/prorationUtils.ts b/server/src/internal/invoices/prorationUtils.ts index d7b6e109e..8443b9d8a 100644 --- a/server/src/internal/invoices/prorationUtils.ts +++ b/server/src/internal/invoices/prorationUtils.ts @@ -1,5 +1,10 @@ import { Decimal } from "decimal.js"; +export type Proration = { + start: number; + end: number; +}; + export const calculateProrationAmount = ({ periodEnd, periodStart, @@ -14,7 +19,10 @@ export const calculateProrationAmount = ({ const num = new Decimal(periodEnd).minus(now); const denom = new Decimal(periodEnd).minus(periodStart); - const proratedAmount = num.div(denom).mul(amount).div(100).toDecimalPlaces(2); + const proratedAmount = num.div(denom).mul(amount); + if (proratedAmount.lte(0)) { + return 0; + } - return proratedAmount; + return proratedAmount.toNumber(); }; diff --git a/server/src/internal/mainRouter.ts b/server/src/internal/mainRouter.ts index 5e4064745..88918b9dd 100644 --- a/server/src/internal/mainRouter.ts +++ b/server/src/internal/mainRouter.ts @@ -36,10 +36,6 @@ mainRouter.use("/test", testRouter); mainRouter.use( "/api/autumn", withOrgAuth, - (req: any, res: any, next: any) => { - // console.log("Autumn middleware:", req.originalUrl); - next(); - }, autumnHandler({ identify: async (req: any) => { return { diff --git a/server/src/internal/orgs/OrgService.ts b/server/src/internal/orgs/OrgService.ts index 707c97f59..e1c175b7a 100644 --- a/server/src/internal/orgs/OrgService.ts +++ b/server/src/internal/orgs/OrgService.ts @@ -82,10 +82,12 @@ export class OrgService { db, orgId, env, + allowNotFound = false, }: { db: DrizzleCli; orgId: string; env: AppEnv; + allowNotFound?: boolean; }) { const result = (await db.query.organizations.findFirst({ where: eq(organizations.id, orgId), @@ -99,6 +101,10 @@ export class OrgService { }; if (!result) { + if (allowNotFound) { + return null; + } + throw new RecaseError({ message: `Organization ${orgId} not found`, code: ErrCode.OrgNotFound, diff --git a/server/src/internal/products/compareProductUtils.ts b/server/src/internal/products/compareProductUtils.ts index 611d54f68..ac82d2494 100644 --- a/server/src/internal/products/compareProductUtils.ts +++ b/server/src/internal/products/compareProductUtils.ts @@ -3,6 +3,7 @@ import { Feature, FullProduct, Product, + ProductItem, ProductV2, } from "@autumn/shared"; import { mapToProductItems } from "./productV2Utils.js"; @@ -12,29 +13,33 @@ import { } from "./product-items/compareItemUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { freeTrialsAreSame } from "./free-trials/freeTrialUtils.js"; -import { isFeatureItem } from "./product-items/getItemType.js"; +import { + isFeatureItem, + isFeaturePriceItem, + isPriceItem, +} from "./product-items/getItemType.js"; export const productsAreSame = ({ - v1Product1, - v1Product2, - v2Product1, - v2Product2, + newProductV1, + newProductV2, + curProductV1, + curProductV2, features, }: { - v1Product1?: FullProduct; - v1Product2?: FullProduct; - v2Product1?: ProductV2; - v2Product2?: ProductV2; + newProductV1?: FullProduct; + newProductV2?: ProductV2; + curProductV1?: FullProduct; + curProductV2?: ProductV2; features: Feature[]; }) => { - if (!v1Product1 && !v2Product1) { + if (!newProductV1 && !newProductV2) { throw new RecaseError({ message: "productsAreSame error: product1 not provided", code: ErrCode.InvalidRequest, }); } - if (!v1Product2 && !v2Product2) { + if (!curProductV1 && !curProductV2) { throw new RecaseError({ message: "productsAreSame error: product2 not provided", code: ErrCode.InvalidRequest, @@ -42,18 +47,18 @@ export const productsAreSame = ({ } let items1 = - v2Product1?.items || + newProductV2?.items || mapToProductItems({ - prices: v1Product1?.prices || [], - entitlements: v1Product1?.entitlements || [], + prices: newProductV1?.prices || [], + entitlements: newProductV1?.entitlements || [], features, }); let items2 = - v2Product2?.items || + curProductV2?.items || mapToProductItems({ - prices: v1Product2?.prices || [], - entitlements: v1Product2?.entitlements || [], + prices: curProductV1?.prices || [], + entitlements: curProductV1?.entitlements || [], features, }); @@ -64,12 +69,26 @@ export const productsAreSame = ({ let priceChanged = false; + const newItems: ProductItem[] = []; + for (const item of items1) { let similarItem = findSimilarItem({ item, items: items2, }); + if (!similarItem) { + // price is different probs... + if (isFeaturePriceItem(item) || isPriceItem(item)) { + priceChanged = true; + } + + itemsSame = false; + newItems.push(item); + + continue; + } + if ( !itemsAreSame({ item1: item, @@ -81,12 +100,13 @@ export const productsAreSame = ({ if (!isFeatureItem(item)) { priceChanged = true; } + newItems.push(item); } } // Compare free trial - let freeTrial1 = v1Product1?.free_trial || v2Product1?.free_trial; - let freeTrial2 = v1Product2?.free_trial || v2Product2?.free_trial; + let freeTrial1 = curProductV1?.free_trial || curProductV2?.free_trial; + let freeTrial2 = newProductV1?.free_trial || newProductV2?.free_trial; let freeTrialsSame = freeTrialsAreSame({ ft1: freeTrial1, @@ -98,5 +118,6 @@ export const productsAreSame = ({ itemsSame, freeTrialsSame, onlyEntsChanged: !itemsSame && !priceChanged, + newItems, }; }; diff --git a/server/src/internal/products/free-trials/freeTrialUtils.ts b/server/src/internal/products/free-trials/freeTrialUtils.ts index 97699ef38..828894820 100644 --- a/server/src/internal/products/free-trials/freeTrialUtils.ts +++ b/server/src/internal/products/free-trials/freeTrialUtils.ts @@ -58,7 +58,15 @@ export const freeTrialsAreSame = ({ ); }; -export const freeTrialToStripeTimestamp = (freeTrial: FreeTrial | null) => { +export const freeTrialToStripeTimestamp = ({ + freeTrial, + now, +}: { + freeTrial: FreeTrial | null | undefined; + now?: number | undefined; +}) => { + now = now || Date.now(); + if (!freeTrial) return undefined; let duration = freeTrial.duration || FreeTrialDuration.Day; @@ -66,11 +74,11 @@ export const freeTrialToStripeTimestamp = (freeTrial: FreeTrial | null) => { let trialEnd: Date; if (duration === FreeTrialDuration.Day) { - trialEnd = addDays(new Date(), length); + trialEnd = addDays(new Date(now), length); } else if (duration === FreeTrialDuration.Month) { - trialEnd = addMonths(new Date(), length); + trialEnd = addMonths(new Date(now), length); } else if (duration === FreeTrialDuration.Year) { - trialEnd = addYears(new Date(), length); + trialEnd = addYears(new Date(now), length); } else { throw new RecaseError({ message: `Invalid free trial duration: ${duration}`, diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 1844ea71c..a5256a9dc 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -15,6 +15,7 @@ import { mapToProductV2 } from "./productV2Utils.js"; import { isFeaturePriceItem } from "./product-items/getItemType.js"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; +import { createOrgResponse } from "../orgs/orgUtils.js"; export const productRouter = Router({ mergeParams: true }); @@ -42,15 +43,7 @@ productRouter.get("/data", async (req: any, res) => { }), versionCounts: getProductVersionCounts(products), features, - org: { - id: org.id, - name: org.name, - // test_pkey: org.test_pkey, - // live_pkey: org.live_pkey, - default_currency: org.default_currency, - stripe_connected: org.stripe_connected, - }, - // coupons, + org: createOrgResponse(org), rewards: coupons, rewardPrograms, }); diff --git a/server/src/internal/products/prices/billingIntervalUtils.ts b/server/src/internal/products/prices/billingIntervalUtils.ts index 11fc7d0f6..c7308de1c 100644 --- a/server/src/internal/products/prices/billingIntervalUtils.ts +++ b/server/src/internal/products/prices/billingIntervalUtils.ts @@ -83,10 +83,12 @@ export const getAlignedIntervalUnix = ({ alignWithUnix, interval, now, + alwaysReturn, }: { alignWithUnix: number; interval: BillingInterval; now?: number; + alwaysReturn?: boolean; }) => { const nextCycleAnchor = alignWithUnix; let nextCycleAnchorUnix = nextCycleAnchor; @@ -122,7 +124,11 @@ export const getAlignedIntervalUnix = ({ new Date(nextCycleAnchorUnix), ) < 60 ) { - billingCycleAnchorUnix = undefined; + if (alwaysReturn) { + return naturalBillingDate; + } else { + billingCycleAnchorUnix = undefined; + } } return billingCycleAnchorUnix; diff --git a/server/src/internal/products/prices/priceUtils/convertPrice.ts b/server/src/internal/products/prices/priceUtils/convertPrice.ts new file mode 100644 index 000000000..c2ccfd3a7 --- /dev/null +++ b/server/src/internal/products/prices/priceUtils/convertPrice.ts @@ -0,0 +1,31 @@ +import { + BillingType, + EntitlementWithFeature, + UsageModel, +} from "@autumn/shared"; + +import { Price } from "@autumn/shared"; +import { getBillingType, getPriceEntitlement } from "../priceUtils.js"; +import { isFixedPrice } from "./usagePriceUtils.js"; + +export const priceToFeature = ({ + price, + ents, +}: { + price: Price; + ents: EntitlementWithFeature[]; +}) => { + const ent = getPriceEntitlement(price, ents); + return ent.feature; +}; + +export const priceToUsageModel = (price: Price) => { + let billingType = getBillingType(price.config); + if (isFixedPrice({ price })) { + return undefined; + } + if (billingType == BillingType.UsageInAdvance) { + return UsageModel.Prepaid; + } + return UsageModel.PayPerUse; +}; diff --git a/server/src/internal/products/prices/priceUtils/findPriceUtils.ts b/server/src/internal/products/prices/priceUtils/findPriceUtils.ts index fcc42ee14..b2fd2a071 100644 --- a/server/src/internal/products/prices/priceUtils/findPriceUtils.ts +++ b/server/src/internal/products/prices/priceUtils/findPriceUtils.ts @@ -1,5 +1,6 @@ import { BillingType, + ErrCode, Price, PriceType, UsagePriceConfig, @@ -8,6 +9,7 @@ import { import { Feature } from "@autumn/shared"; import { getBillingType } from "../priceUtils.js"; import Stripe from "stripe"; +import RecaseError from "@/utils/errorUtils.js"; export const findPrepaidPrice = ({ prices, @@ -29,3 +31,34 @@ export const findPrepaidPrice = ({ } else return true; }); }; + +export const findPriceForFeature = ({ + prices, + feature, + internalFeatureId, +}: { + prices: Price[]; + feature?: Feature; + internalFeatureId?: string; +}) => { + if (!feature && !internalFeatureId) { + throw new RecaseError({ + message: "findPriceForFeature: No feature or internalFeatureId provided", + code: ErrCode.InternalError, + }); + } + + return prices.find((p: Price) => { + const config = p.config as UsagePriceConfig; + + if (!config.internal_feature_id) { + return false; + } + + if (internalFeatureId) { + return config.internal_feature_id == internalFeatureId; + } else { + return config.internal_feature_id == feature!.internal_id; + } + }); +}; diff --git a/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts b/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts index 57103ba89..e0559b738 100644 --- a/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts +++ b/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts @@ -8,8 +8,28 @@ const BillingIntervalOrder = [ BillingInterval.OneOff, ]; +const ReversedBillingIntervalOrder = [ + BillingInterval.OneOff, + BillingInterval.Month, + BillingInterval.Quarter, + BillingInterval.SemiAnnual, + BillingInterval.Year, +]; + export const getFirstInterval = ({ prices }: { prices: Price[] }) => { return BillingIntervalOrder.find((interval) => prices.some((price) => price.config.interval === interval), )!; }; + +export const getLastInterval = ({ prices }: { prices: Price[] }) => { + return ReversedBillingIntervalOrder.find((interval) => + prices.some((price) => price.config.interval === interval), + )!; +}; + +export const sortBillingIntervals = (intervals: BillingInterval[]) => { + return intervals.sort((a, b) => { + return BillingIntervalOrder.indexOf(a) - BillingIntervalOrder.indexOf(b); + }); +}; diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts index 21f83ff96..b9e921ede 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts @@ -18,3 +18,9 @@ export const isFixedPrice = ({ price }: { price: Price }) => { billingType == BillingType.FixedCycle || billingType == BillingType.OneOff ); }; + +export const hasPrepaidPrice = ({ prices }: { prices: Price[] }) => { + return prices.some( + (price) => getBillingType(price.config) == BillingType.UsageInAdvance, + ); +}; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index f4a0960f9..bdd797bd7 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -16,20 +16,57 @@ import { billingToItemInterval, entToItemInterval, } from "./itemIntervalUtils.js"; +import { + calculateProrationAmount, + Proration, +} from "@/internal/invoices/prorationUtils.js"; -export const itemToPriceOrTiers = (item: ProductItem) => { +export const itemToPriceOrTiers = ({ + item, + proration, + now, +}: { + item: ProductItem; + proration?: Proration; + now?: number; +}) => { + now = now || Date.now(); if (item.price) { return { - price: item.price, + price: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: item.price, + }) + : item.price, }; } else if (item.tiers) { if (item.tiers.length > 1) { return { - tiers: item.tiers, + tiers: item.tiers.map((tier) => ({ + ...tier, + amount: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: tier.amount, + }) + : tier.amount, + })), }; } else { return { - price: item.tiers[0].amount, + price: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: item.tiers[0].amount, + }) + : item.tiers[0].amount, }; } } diff --git a/server/src/internal/products/productUtils/classifyProduct.ts b/server/src/internal/products/productUtils/classifyProduct.ts new file mode 100644 index 000000000..91c7c5fce --- /dev/null +++ b/server/src/internal/products/productUtils/classifyProduct.ts @@ -0,0 +1,30 @@ +import { FullProduct, Price, ProductV2 } from "@autumn/shared"; +import { pricesOnlyOneOff } from "../prices/priceUtils.js"; + +export const prodIsAddOn = ({ product }: { product: FullProduct }) => { + return product.is_add_on; +}; + +export const oneOffOrAddOn = ({ + product, + prices, +}: { + product: FullProduct; + prices?: Price[]; +}) => { + const isOneOff = prices + ? pricesOnlyOneOff(prices) + : pricesOnlyOneOff(product.prices); + + return prodIsAddOn({ product }) || isOneOff; +}; + +export const isMainProduct = ({ + product, + prices, +}: { + product: FullProduct; + prices?: Price[]; +}) => { + return !prodIsAddOn({ product }) && !oneOffOrAddOn({ product, prices }); +}; diff --git a/server/src/internal/rewards/referralUtils.ts b/server/src/internal/rewards/referralUtils.ts index 65a7a0e5b..43f4466ef 100644 --- a/server/src/internal/rewards/referralUtils.ts +++ b/server/src/internal/rewards/referralUtils.ts @@ -199,6 +199,7 @@ export const triggerFreeProduct = async ({ await createFullCusProduct({ db, attachParams: redeemerAttachParams, + logger, }); logger.info(`✅ Added ${fullProduct.name} to redeemer`); } @@ -211,6 +212,7 @@ export const triggerFreeProduct = async ({ customer: referrer, cusProducts: referrerCusProducts, }, + logger, }); logger.info(`✅ Added ${fullProduct.name} to referrer`); } diff --git a/server/src/utils/formatUtils.ts b/server/src/utils/formatUtils.ts new file mode 100644 index 000000000..9a6ac0835 --- /dev/null +++ b/server/src/utils/formatUtils.ts @@ -0,0 +1,18 @@ +import { Organization } from "@autumn/shared"; + +export const formatAmount = ({ + org, + amount, + maxFractionDigits = 2, +}: { + org?: Organization; + amount: number; + maxFractionDigits?: number; +}) => { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: org?.default_currency || "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount); +}; diff --git a/server/src/utils/genUtils.ts b/server/src/utils/genUtils.ts index 40fa654da..04b4c5e80 100644 --- a/server/src/utils/genUtils.ts +++ b/server/src/utils/genUtils.ts @@ -53,11 +53,11 @@ export const notNullish = (value: any) => { return !nullish(value); }; -export const formatUnixToDateTime = (unixDate: number) => { +export const formatUnixToDateTime = (unixDate?: number | null) => { if (!unixDate) { - return null; + return "undefined unix date"; } - return format(new Date(unixDate), "yyyy MMM dd HH:mm:ss"); + return format(new Date(unixDate), "dd MMM yyyy HH:mm:ss"); }; export const formatUnixToDate = (unixDate?: number) => { diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 1a0a2bcc2..710d1e38a 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -1,14 +1,24 @@ import { ProductItem, ProductItemInterval, UsageModel } from "@autumn/shared"; -export const constructPrepaidItem = ({ featureId }: { featureId: string }) => { +export const constructPrepaidItem = ({ + featureId, + price, + billingUnits = 100, + isOneOff = false, +}: { + featureId: string; + price: number; + billingUnits?: number; + isOneOff?: boolean; +}) => { let item: ProductItem = { feature_id: featureId, usage_model: UsageModel.Prepaid, - price: 100, - billing_units: 100, + price: price, + billing_units: billingUnits || 100, - interval: ProductItemInterval.Month, + interval: isOneOff ? null : ProductItemInterval.Month, }; return item; @@ -22,6 +32,7 @@ export const constructArrearItem = ({ featureId }: { featureId: string }) => { price: 0.1, billing_units: 1000, interval: ProductItemInterval.Month, + reset_usage_when_enabled: true, }; return item; diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 985d2f520..7d92f69f6 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -6,8 +6,10 @@ import { import { AppEnv, BillingInterval, + CreateFreeTrialSchema, Feature, FeatureUsageType, + FreeTrialDuration, Product, ProductItem, ProductV2, @@ -70,12 +72,14 @@ export const constructProduct = ({ items, type, isAnnual = false, + trial = false, }: { items: ProductItem[]; - type: "free" | "pro" | "premium"; + type: "free" | "pro" | "premium" | "one_off"; isAnnual?: boolean; + trial?: boolean; }) => { - let price = type == "pro" ? 20 : 50; + let price = type == "pro" ? 20 : type == "premium" ? 50 : 0; if (price) { items.push( @@ -94,7 +98,13 @@ export const constructProduct = ({ is_default: false, version: 1, group: "", - free_trial: null, + free_trial: trial + ? (CreateFreeTrialSchema.parse({ + length: 7, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + }) as any) + : null, }; return product; diff --git a/shared/index.ts b/shared/index.ts index fa4b19a68..3948a330a 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -113,6 +113,12 @@ export * from "./models/migrationModels/migrationErrorTable.js"; export * from "./models/analyticsModels/actionEnums.js"; export * from "./models/analyticsModels/actionTable.js"; +// Attach Models +export * from "./models/attachModels/attachPreviewModels.js"; +export * from "./models/attachModels/attachEnums/AttachBranch.js"; +export * from "./models/attachModels/attachEnums/AttachFunction.js"; +export * from "./models/attachModels/attachEnums/AttachConfig.js"; + // Utils export * from "./utils/displayUtils.js"; export * from "./models/checkModels/checkPreviewModels.js"; diff --git a/shared/models/attachModels/attachEnums/AttachBranch.ts b/shared/models/attachModels/attachEnums/AttachBranch.ts new file mode 100644 index 000000000..3241b4237 --- /dev/null +++ b/shared/models/attachModels/attachEnums/AttachBranch.ts @@ -0,0 +1,21 @@ +export enum AttachBranch { + MultiProduct = "multi_product", + + OneOff = "one_off", + + New = "new", + AddOn = "add_on", + + // Same product + NewVersion = "new_version", + SameCustomEnts = "same_custom_ents", + SameCustom = "same_custom", + UpdatePrepaidQuantity = "update_prepaid_quantity", + Renew = "renew", + + // Handle upgrades / downgrades + MainIsFree = "main_is_free", + MainIsTrial = "main_is_trial", + Upgrade = "upgrade", + Downgrade = "downgrade", +} diff --git a/shared/models/attachModels/attachEnums/AttachConfig.ts b/shared/models/attachModels/attachEnums/AttachConfig.ts new file mode 100644 index 000000000..8c6f08be9 --- /dev/null +++ b/shared/models/attachModels/attachEnums/AttachConfig.ts @@ -0,0 +1,15 @@ +import { AttachBranch } from "./AttachBranch.js"; + +export enum ProrationBehavior { + Immediately = "immediately", + NextBilling = "next_billing", + None = "none", +} + +export interface AttachConfig { + onlyCheckout: boolean; + carryUsage: boolean; // Whether to carry over existing usages + branch: AttachBranch; + proration: ProrationBehavior; + disableTrial: boolean; +} diff --git a/shared/models/attachModels/attachEnums/AttachFunction.ts b/shared/models/attachModels/attachEnums/AttachFunction.ts new file mode 100644 index 000000000..c7dd45d41 --- /dev/null +++ b/shared/models/attachModels/attachEnums/AttachFunction.ts @@ -0,0 +1,15 @@ +export enum AttachFunction { + CreateCheckout = "create_checkout", + AddProduct = "add_product", + UpdateEnts = "update_ents", // only update entitlements + UpdateProduct = "update_product", // update product + ScheduleProduct = "schedule_product", + UpdatePrepaidQuantity = "update_prepaid_quantity", + Renew = "renew", +} + +/* Handle checkout / public error: +1. New version, same custom, same custom ents, renew, update prepaid quantity + +2. +*/ diff --git a/shared/models/attachModels/attachPreviewModels.ts b/shared/models/attachModels/attachPreviewModels.ts new file mode 100644 index 000000000..8e057a9c3 --- /dev/null +++ b/shared/models/attachModels/attachPreviewModels.ts @@ -0,0 +1,7 @@ +import { AttachBranch } from "./attachEnums/AttachBranch.js"; + +export interface AttachPreview { + branch: AttachBranch; + options: any; + new_items: any; +} diff --git a/shared/models/checkModels/checkPreviewModels.ts b/shared/models/checkModels/checkPreviewModels.ts index 3698cf71c..f697dd63f 100644 --- a/shared/models/checkModels/checkPreviewModels.ts +++ b/shared/models/checkModels/checkPreviewModels.ts @@ -16,6 +16,13 @@ export enum AttachScenario { Expired = "expired", } +export interface PreviewItem { + price: string; + description: string; + usage_model?: UsageModel; + amount?: number; +} + export interface CheckProductPreview { title: string; message: string; diff --git a/shared/utils/displayUtils.ts b/shared/utils/displayUtils.ts index 256fb7fe7..63e3223d3 100644 --- a/shared/utils/displayUtils.ts +++ b/shared/utils/displayUtils.ts @@ -41,3 +41,36 @@ export const getFeatureNameWithCapital = ({ return feature.name; }; + +export const getSingularAndPlural = ({ + feature, + capitalize = false, +}: { + feature: Feature; + capitalize?: boolean; +}) => { + return { + singular: getFeatureName({ feature, plural: false, capitalize }), + plural: getFeatureName({ feature, plural: true, capitalize }), + }; +}; + +export const getFeatureInvoiceDescription = ({ + feature, + usage, + billingUnits = 1, +}: { + feature: Feature; + usage: number; + billingUnits?: number; +}) => { + const { singular, plural } = getSingularAndPlural({ feature }); + + if (billingUnits == 1) { + if (usage == 1) + return `${usage} ${singular}`; // eg. 1 credit + else return `${usage} ${plural}`; // eg. 4 credits + } else { + return `${usage} x ${billingUnits} ${plural}`; // eg. 4 x 100 credits + } +}; diff --git a/vite/src/components/pricing/attach-pricing-dialog.tsx b/vite/src/components/pricing/attach-pricing-dialog.tsx index 6d526e498..256eaf541 100644 --- a/vite/src/components/pricing/attach-pricing-dialog.tsx +++ b/vite/src/components/pricing/attach-pricing-dialog.tsx @@ -11,7 +11,7 @@ export const PriceItem = ({ return (
{ return format(new Date(timestamp), "MM/dd/yyyy"); }; +export const formatUnixToDate = (unix: number | null | undefined) => { + if (!unix) return ""; + return format(new Date(unix), "d MMM yyyy"); +}; + export const formatUnixToDateTime = (unix: number | null | undefined) => { if (!unix) return { date: "", time: "" }; const date = format(new Date(unix), "d MMM"); diff --git a/vite/src/utils/formatUtils/formatTextUtils.ts b/vite/src/utils/formatUtils/formatTextUtils.ts index 2b522d9cb..be1197b79 100644 --- a/vite/src/utils/formatUtils/formatTextUtils.ts +++ b/vite/src/utils/formatUtils/formatTextUtils.ts @@ -18,9 +18,17 @@ export const slugify = (text: string) => { .replace(/[^\w\s-]/g, ""); }; -export const formatCurrency = (amount: number, currency: string = "USD") => { +export const formatAmount = ({ + amount, + currency, +}: { + amount: number; + currency: string; +}) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 10, }).format(amount); }; diff --git a/vite/src/utils/linkUtils.ts b/vite/src/utils/linkUtils.ts index 19a0256ef..e11c60a3c 100644 --- a/vite/src/utils/linkUtils.ts +++ b/vite/src/utils/linkUtils.ts @@ -11,6 +11,11 @@ export const getStripeSubLink = (subscriptionId: string, env: AppEnv) => { env == AppEnv.Live ? "" : "/test" }/subscriptions/${subscriptionId}`; }; +export const getStripeSubScheduleLink = (scheduledId: string, env: AppEnv) => { + return `https://dashboard.stripe.com${ + env == AppEnv.Live ? "" : "/test" + }/subscription_schedules/${scheduledId}`; +}; export const getStripeInvoiceLink = (stripeInvoice: any) => { return `https://dashboard.stripe.com${ diff --git a/vite/src/utils/product/product-item/formatProductItem.ts b/vite/src/utils/product/product-item/formatProductItem.ts index 50e16b62c..9e5145b29 100644 --- a/vite/src/utils/product/product-item/formatProductItem.ts +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -1,10 +1,14 @@ import { Feature, + FeatureType, + Infinite, Organization, ProductItem, ProductItemType, } from "@autumn/shared"; import { formatAmount, getItemType, intervalIsNone } from "../productItemUtils"; +import { getFeature } from "../entitlementUtils"; +import { notNullish } from "@/utils/genUtils"; const getPaidFeatureString = ({ item, @@ -37,7 +41,7 @@ const getPaidFeatureString = ({ })}`; } - let feature = features.find((f: Feature) => f.id == item.feature_id); + const feature = features.find((f: Feature) => f.id == item.feature_id); amountStr += ` per ${item.billing_units! > 1 ? item.billing_units : ""} ${ feature?.name @@ -61,8 +65,8 @@ const getFixedPriceString = ({ item: ProductItem; org: Organization; }) => { - let currency = org?.default_currency || "USD"; - let formattedAmount = formatAmount({ + const currency = org?.default_currency || "USD"; + const formattedAmount = formatAmount({ defaultCurrency: currency, amount: item.price!, }); @@ -74,6 +78,26 @@ const getFixedPriceString = ({ return `${formattedAmount}`; }; +export const getFeatureString = ({ + item, + features, +}: { + item: ProductItem; + features: Feature[]; +}) => { + const feature = features.find((f: Feature) => f.id == item.feature_id); + + if (feature?.type === FeatureType.Boolean) { + return `${feature.name}`; + } + + if (item.included_usage == Infinite) { + return `Unlimited ${feature?.name}`; + } + + return `${item.included_usage ?? 0} ${feature?.name}${item.entity_feature_id ? ` per ${getFeature(item.entity_feature_id, features)?.name}` : ""}${notNullish(item.interval) ? ` per ${item.interval}` : ""}`; +}; + export const formatProductItemText = ({ item, org, @@ -83,7 +107,7 @@ export const formatProductItemText = ({ org: Organization; features: Feature[]; }) => { - let itemType = getItemType(item); + const itemType = getItemType(item); if (itemType == ProductItemType.FeaturePrice) { return getPaidFeatureString({ item, org, features }); diff --git a/vite/src/utils/product/productItemUtils.ts b/vite/src/utils/product/productItemUtils.ts index dc07f6fc0..40d03188e 100644 --- a/vite/src/utils/product/productItemUtils.ts +++ b/vite/src/utils/product/productItemUtils.ts @@ -16,15 +16,17 @@ export const itemIsUnlimited = (item: ProductItem) => { export const formatAmount = ({ defaultCurrency, amount, + maxFractionDigits = 6, }: { defaultCurrency: string; amount: number; + maxFractionDigits?: number; }) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: defaultCurrency, minimumFractionDigits: 0, - maximumFractionDigits: 6, + maximumFractionDigits: maxFractionDigits || 6, }).format(amount); }; diff --git a/vite/src/utils/productUtils.ts b/vite/src/utils/productUtils.ts new file mode 100644 index 000000000..d1fbcb8af --- /dev/null +++ b/vite/src/utils/productUtils.ts @@ -0,0 +1,21 @@ +import { ProductItemType } from "@autumn/shared"; + +import { ProductItem } from "@autumn/shared"; +import { getItemType } from "./product/productItemUtils"; + +export const sortProductItems = (items: ProductItem[]) => { + const sortedItems = [...items].sort((a, b) => { + const typeA = getItemType(a); + const typeB = getItemType(b); + + const typeOrder = { + [ProductItemType.Feature]: 0, + [ProductItemType.FeaturePrice]: 1, + [ProductItemType.Price]: 2, + }; + + return typeOrder[typeA] - typeOrder[typeB]; + }); + + return sortedItems; +}; diff --git a/vite/src/views/customers/customer/CustomerProductList.tsx b/vite/src/views/customers/customer/CustomerProductList.tsx index 973791f7a..e9b272c87 100644 --- a/vite/src/views/customers/customer/CustomerProductList.tsx +++ b/vite/src/views/customers/customer/CustomerProductList.tsx @@ -33,6 +33,7 @@ import { AdminHover } from "@/components/general/AdminHover"; import AddProduct from "./add-product/NewProductDropdown"; import { Item, Row } from "@/components/general/TableGrid"; import { cn } from "@/lib/utils"; +import { CusProductStripeLink } from "./components/CusProductStripeLink"; export const CustomerProductList = ({ customer, @@ -59,7 +60,7 @@ export const CustomerProductList = ({ p.entitlements.some( (cusEnt: any) => cusEnt.entities && - Object.keys(cusEnt.entities).includes(entity.internal_id) + Object.keys(cusEnt.entities).includes(entity.internal_id), ) : true; @@ -88,7 +89,7 @@ export const CustomerProductList = ({ variant="ghost" className={cn( "text-t3 text-xs font-normal p-0", - showExpired && "text-t1 hover:text-t1" + showExpired && "text-t1 hover:text-t1", )} size="sm" onClick={() => setShowExpired(!showExpired)} @@ -119,8 +120,8 @@ export const CustomerProductList = ({ key={cusProduct.id} className="grid-cols-12 pr-0" onClick={() => { - let entity = entities.find( - (e: any) => e.internal_id === cusProduct.internal_entity_id + const entity = entities.find( + (e: any) => e.internal_id === cusProduct.internal_entity_id, ); navigateTo( `/customers/${customer.id || customer.internal_id}/${ @@ -129,7 +130,7 @@ export const CustomerProductList = ({ entity ? `&entity_id=${entity.id || entity.internal_id}` : "" }`, navigate, - env + env, ); }} > @@ -188,30 +189,7 @@ export const CustomerProductList = ({ scheduled )} - {cusProduct.subscription_ids && - cusProduct.subscription_ids.length > 0 && ( - - {cusProduct.subscription_ids.map((subId: string) => { - return ( - { - e.stopPropagation(); - }} - > -
- -
- - ); - })} -
- )} +
@@ -285,7 +263,7 @@ const UpdateStatusDropdownBtn = ({ cusProduct.id, { status, - } + }, ); await cusMutate(); } catch (error) { diff --git a/vite/src/views/customers/customer/components/CusProductStripeLink.tsx b/vite/src/views/customers/customer/components/CusProductStripeLink.tsx new file mode 100644 index 000000000..07574ae32 --- /dev/null +++ b/vite/src/views/customers/customer/components/CusProductStripeLink.tsx @@ -0,0 +1,67 @@ +import { useEnv } from "@/utils/envUtils"; +import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils"; +import { CusProductStatus, FullCusProduct } from "@autumn/shared"; +import { ArrowUpRightFromSquare } from "lucide-react"; +import React from "react"; +import { Link } from "react-router"; + +export const CusProductStripeLink = ({ + cusProduct, +}: { + cusProduct: FullCusProduct; +}) => { + const env = useEnv(); + return ( + <> + {cusProduct.subscription_ids && + cusProduct.subscription_ids.length > 0 && ( + + {cusProduct.subscription_ids.map((subId: string) => { + return ( + { + e.stopPropagation(); + }} + > +
+ +
+ + ); + })} +
+ )} + {cusProduct.status == CusProductStatus.Scheduled && + cusProduct.scheduled_ids && + cusProduct.scheduled_ids.length > 0 && ( + + {cusProduct.scheduled_ids.map((subId: string) => { + return ( + { + e.stopPropagation(); + }} + > +
+ +
+ + ); + })} +
+ )} + + ); +}; diff --git a/vite/src/views/customers/customer/product/CustomerProductView.tsx b/vite/src/views/customers/customer/product/CustomerProductView.tsx index 42e9ab2f7..251f6854b 100644 --- a/vite/src/views/customers/customer/product/CustomerProductView.tsx +++ b/vite/src/views/customers/customer/product/CustomerProductView.tsx @@ -1,7 +1,8 @@ "use client"; +import ProductSidebar from "@/views/products/product/ProductSidebar"; import LoadingScreen from "@/views/general/LoadingScreen"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { Customer, Entity, @@ -14,16 +15,6 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { CustomToaster } from "@/components/general/CustomToaster"; import { ManageProduct } from "@/views/products/product/ManageProduct"; import { ProductContext } from "@/views/products/product/ProductContext"; -import { CusService } from "@/services/customers/CusService"; -import { toast } from "sonner"; - -import { - BreadcrumbItem, - Breadcrumb, - BreadcrumbList, - BreadcrumbSeparator, - BreadcrumbLink, -} from "@/components/ui/breadcrumb"; import { Dialog, @@ -33,26 +24,11 @@ import { } from "@/components/ui/dialog"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; - -import { - getBackendErr, - getBackendErrObj, - getRedirectUrl, - navigateTo, -} from "@/utils/genUtils"; - -import { ErrCode } from "@autumn/shared"; -import { AddProductButton } from "../add-product/AddProductButton"; import ErrorScreen from "@/views/general/ErrorScreen"; - -import { ProductService } from "@/services/products/ProductService"; -import RequiredOptionsModal from "./RequiredOptionsModal"; import { ProductOptions } from "./ProductOptions"; - import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { useEnv } from "@/utils/envUtils"; -import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import ProductSidebar from "@/views/products/product/ProductSidebar"; + import { FeaturesContext } from "@/views/features/FeaturesContext"; import { CustomerProductBreadcrumbs } from "./components/CustomerProductBreadcrumbs"; import { useAttachState } from "./hooks/useAttachState"; @@ -122,12 +98,14 @@ export default function CustomerProductView() { const [url, setUrl] = useState(null); const [checkoutDialogOpen, setCheckoutDialogOpen] = useState(false); - const [requiredOptions, setRequiredOptions] = useState([]); - const [useInvoice, setUseInvoice] = useState(false); const [selectedEntitlementAllowance, setSelectedEntitlementAllowance] = useState<"unlimited" | number>(0); - const attachState = useAttachState({ product, preview: data?.preview }); + const attachState = useAttachState({ + product, + setProduct, + preview: data?.preview, + }); useEffect(() => { if (!data?.product || !data?.customer) return; @@ -188,8 +166,6 @@ export default function CustomerProductView() { setSelectedEntitlementAllowance, customer: customer as Customer, entities: data.entities as Entity[], - - setUseInvoice, entityId, setEntityId, attachState, diff --git a/vite/src/views/customers/customer/product/components/AttachButton.tsx b/vite/src/views/customers/customer/product/components/AttachButton.tsx index 00ec11711..c3abba0a5 100644 --- a/vite/src/views/customers/customer/product/components/AttachButton.tsx +++ b/vite/src/views/customers/customer/product/components/AttachButton.tsx @@ -41,7 +41,7 @@ const getAttachBody = ({ options: optionsInput ? optionsInput.map((option) => ({ feature_id: option.feature_id, - quantity: option.quantity, + quantity: option.quantity || 0, })) : undefined, is_custom: isCustom, @@ -54,11 +54,13 @@ const getAttachBody = ({ }; export const AttachButton = () => { - const [open, setOpen] = useState(false); - const [preview, setPreview] = useState(null); - const { attachState, product, entityId, customer } = useProductContext(); - const [buttonLoading, setButtonLoading] = useState(false); const axios = useAxiosInstance(); + const [open, setOpen] = useState(false); + const [buttonLoading, setButtonLoading] = useState(false); + + const { attachState, product, entityId, customer } = useProductContext(); + const { preview, setPreview } = attachState; + const { buttonText } = attachState; const handleAttachClicked = async () => { @@ -74,8 +76,7 @@ export const AttachButton = () => { }), ); - setPreview(res.data.preview); - + setPreview(res.data); setOpen(true); } catch (error) { toast.error(getBackendErr(error, "Failed to attach product")); @@ -85,7 +86,7 @@ export const AttachButton = () => { return ( <> - + + + {invoiceAllowed() && ( + + )}