diff --git a/example/src/app/globals.css b/example/src/app/globals.css index 69393ad85..09dfdcc4d 100644 --- a/example/src/app/globals.css +++ b/example/src/app/globals.css @@ -21,6 +21,14 @@ button:hover { --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + + /* Custom font sizes */ + --text-xs: 12px; /* Changed from default 11px to 12px */ + --text-sm: 13px; + --text-md: 15px; + --text-lg: 17px; + --text-xl: 20px; + --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); diff --git a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts index 75a0efdf1..c6cd67d89 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToStripeItem.ts @@ -11,6 +11,7 @@ import { FixedPriceConfig, FullProduct, Organization, + ProductOptions, UsagePriceConfig, } from "@autumn/shared"; import { EntitlementWithFeature, Price, APIVersion } from "@autumn/shared"; @@ -21,6 +22,7 @@ import { } from "./priceToUsageInAdvance.js"; import { priceToInArrearProrated } from "./priceToArrearProrated.js"; import { billingIntervalToStripe } from "../stripePriceUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; export const getEmptyPriceItem = ({ price, @@ -56,6 +58,7 @@ export const priceToStripeItem = ({ withEntity = false, isCheckout = false, apiVersion, + productOptions, }: { price: Price; relatedEnt: EntitlementWithFeature; @@ -66,11 +69,16 @@ export const priceToStripeItem = ({ withEntity: boolean; isCheckout: boolean; apiVersion?: APIVersion; + productOptions?: ProductOptions | undefined; }) => { // TODO: Implement this const billingType = getBillingType(price.config!); const stripeProductId = product.processor?.id; + const quantityMultiplier = notNullish(productOptions?.quantity) + ? productOptions?.quantity! + : 1; + if (!stripeProductId) { throw new RecaseError({ code: ErrCode.ProductNotFound, @@ -91,7 +99,7 @@ export const priceToStripeItem = ({ lineItem = { price: config.stripe_price_id, - quantity: 1, + quantity: quantityMultiplier, }; } diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 92f23f6b3..308cedb31 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -19,6 +19,7 @@ import { AttachReplaceable, ErrCode, AttachConfig, + ProductOptions, } from "@autumn/shared"; import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js"; import { getArrearItems } from "./getStripeSubItems/getArrearItems.js"; @@ -32,6 +33,7 @@ import { logger } from "@/external/logtail/logtailUtils.js"; import { intervalKeyToPrice, priceToIntervalKey, + priceToProductOptions, } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { ItemSet } from "@/utils/models/ItemSet.js"; @@ -71,6 +73,7 @@ export const getStripeSubItems = async ({ }: { attachParams: { products: FullProduct[]; + productsList?: ProductOptions[]; prices: Price[]; entitlements: EntitlementWithFeature[]; optionsList: FeatureOptions[]; @@ -105,10 +108,15 @@ export const getStripeSubItems = async ({ const prices = intervalToPrices[intervalKey]; let subItems: any[] = []; - let usage_features: any[] = []; for (const price of prices) { + const prodOptions = priceToProductOptions({ + price, + options: attachParams.productsList, + products, + }); + const priceEnt = getPriceEntitlement(price, entitlements); const options = getEntOptions(optionsList, priceEnt); @@ -162,6 +170,7 @@ export const getStripeSubItems = async ({ existingUsage, withEntity: notNullish(attachParams.internalEntityId), apiVersion: attachParams.apiVersion, + productOptions: prodOptions, }); if (!stripeItem) { @@ -228,6 +237,7 @@ export const getStripeSubItems2 = async ({ customer, internalEntityId, apiVersion, + products, } = attachParams; const subItems: any[] = []; @@ -236,6 +246,11 @@ export const getStripeSubItems2 = async ({ for (const price of prices) { const priceEnt = getPriceEntitlement(price, entitlements); const options = getEntOptions(optionsList, priceEnt); + const prodOptions = priceToProductOptions({ + price, + options: attachParams.productsList, + products, + }); let existingUsage = getExistingUsageFromCusProducts({ entitlement: priceEnt, @@ -280,6 +295,7 @@ export const getStripeSubItems2 = async ({ existingUsage, withEntity: notNullish(internalEntityId), apiVersion: attachParams.apiVersion, + productOptions: prodOptions, }); if (isUsagePrice({ price })) { diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 768ae0261..c87124755 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -110,20 +110,51 @@ export const handleCheckoutSessionCompleted = async ({ logger, }); - const products = attachParams.products; + const anchorToUnix = checkoutSub + ? getEarliestPeriodEnd({ sub: checkoutSub! }) * 1000 + : undefined; + if (attachParams.productsList) { + console.log("Inserting products list"); + for (const productOptions of attachParams.productsList) { + const product = attachParams.products.find( + (p) => p.id === productOptions.product_id + ); - for (const product of products) { - const anchorToUnix = checkoutSub - ? getEarliestPeriodEnd({ sub: checkoutSub! }) * 1000 - : undefined; - await createFullCusProduct({ - db, - attachParams: attachToInsertParams(attachParams, product), - subscriptionIds: checkoutSub ? [checkoutSub?.id!] : undefined, - anchorToUnix, - scenario: AttachScenario.New, - logger, - }); + if (!product) { + logger.error( + `checkout.completed: product not found for productOptions: ${JSON.stringify( + productOptions + )}` + ); + continue; + } + + await createFullCusProduct({ + db, + attachParams: attachToInsertParams( + attachParams, + product, + productOptions.entity_id || undefined + ), + subscriptionIds: checkoutSub ? [checkoutSub?.id!] : undefined, + anchorToUnix, + scenario: AttachScenario.New, + logger, + productOptions, + }); + } + } else { + const products = attachParams.products; + for (const product of products) { + await createFullCusProduct({ + db, + attachParams: attachToInsertParams(attachParams, product), + subscriptionIds: checkoutSub ? [checkoutSub?.id!] : undefined, + anchorToUnix, + scenario: AttachScenario.New, + logger, + }); + } } console.log("✅ checkout.completed: successfully created cus product"); diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 743008f00..eb19278c1 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -10,6 +10,7 @@ import { FullCusProduct, APIVersion, InsertReplaceable, + ProductOptions, } from "@autumn/shared"; import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; @@ -78,6 +79,7 @@ export const initCusProduct = ({ entityId, internalEntityId, apiVersion, + quantity, }: { customer: Customer; product: FullProduct; @@ -99,6 +101,7 @@ export const initCusProduct = ({ entityId?: string; internalEntityId?: string; apiVersion?: APIVersion; + quantity?: number; }) => { let isFuture = startsAt && startsAt > Date.now(); @@ -114,6 +117,7 @@ export const initCusProduct = ({ internal_product_id: product.internal_id, product_id: product.id, created_at: createdAt || Date.now(), + canceled: false, status: subscriptionStatus ? subscriptionStatus @@ -137,7 +141,7 @@ export const initCusProduct = ({ subscription_ids: subscriptionIds, scheduled_ids: subscriptionScheduleIds, is_custom: isCustom || false, - quantity: 1, + quantity: quantity || 1, internal_entity_id: internalEntityId, entity_id: entityId, api_version: apiVersion, @@ -269,7 +273,7 @@ export const createFullCusProduct = async ({ // subscriptionId, nextResetAt, disableFreeTrial = false, - lastInvoiceId = null, + productOptions, trialEndsAt, subscriptionStatus, canceledAt = null, @@ -292,7 +296,7 @@ export const createFullCusProduct = async ({ nextResetAt?: number; billLaterOnly?: boolean; disableFreeTrial?: boolean; - lastInvoiceId?: string | null; + productOptions?: ProductOptions; trialEndsAt?: number; subscriptionStatus?: CusProductStatus; canceledAt?: number | null; @@ -354,6 +358,7 @@ export const createFullCusProduct = async ({ logger.info( `Inserting cus product ${product.id} for ${customer.name}, cus product ID: ${cusProdId}` ); + logger.info(productOptions); // 1. create customer entitlements const cusEnts: CustomerEntitlement[] = []; @@ -371,8 +376,6 @@ export const createFullCusProduct = async ({ nextResetAt, freeTrial, relatedPrice, - // existingCusEnt, - // keepResetIntervals, trialEndsAt, anchorToUnix, entities: attachParams.entities || [], @@ -380,6 +383,7 @@ export const createFullCusProduct = async ({ curCusProduct: curCusProduct as FullCusProduct, replaceables: attachReplaceables, now: attachParams.now, + productOptions: productOptions || undefined, }); cusEnts.push(cusEnt); @@ -448,6 +452,7 @@ export const createFullCusProduct = async ({ internalEntityId: attachParams.internalEntityId, entityId: attachParams.entityId, apiVersion: attachParams.apiVersion, + quantity: productOptions?.quantity ?? undefined, }); // Expire previous product if not one off and add on...? diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 767ab402a..15405a5f4 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -7,10 +7,7 @@ import { createStripeCli } from "@/external/stripe/utils.js"; import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js"; import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { - getStripeSubItems, - getStripeSubItems2, -} from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; +import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { ErrCode } from "@/errors/errCodes.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { APIVersion, AttachConfig } from "@autumn/shared"; @@ -47,6 +44,12 @@ export const handleCreateCheckout = async ({ isCheckout: true, }); + for (const itemSet of itemSets) { + for (const item of itemSet.items) { + console.log(item); + } + } + if (itemSets.length === 0) { throw new RecaseError({ code: ErrCode.ProductHasNoPrices, diff --git a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts index a770cab4a..393019b46 100644 --- a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts @@ -8,7 +8,7 @@ import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { isOneOff } from "@/internal/products/productUtils.js"; import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js"; -import { AttachConfig, SuccessCode } from "@autumn/shared"; +import { AttachBranch, AttachConfig, SuccessCode } from "@autumn/shared"; import Stripe from "stripe"; import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js"; @@ -17,16 +17,19 @@ export const handleCreateInvoiceCheckout = async ({ res, attachParams, config, + branch, }: { req: any; res?: any; attachParams: AttachParams; config: AttachConfig; + branch?: AttachBranch; }) => { // if one off const { stripeCli } = attachParams; let invoiceResult; + if (isOneOff(attachParams.prices)) { invoiceResult = await handleOneOffFunction({ req, diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index ea34538b7..0907809d5 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -3,30 +3,25 @@ import { AttachReplaceable, BillingType, Customer, - EntInterval, Entity, EntityBalance, FeatureType, FreeTrial, FullCusProduct, FullCustomerEntitlement, - InsertReplaceable, Price, + ProductOptions, } from "@autumn/shared"; import { FeatureOptions } from "@autumn/shared"; import { EntitlementWithFeature } from "@autumn/shared"; import { getResetBalance } from "../cusProducts/cusEnts/cusEntUtils.js"; -import { formatUnixToDate, generateId, notNullish } from "@/utils/genUtils.js"; +import { generateId, notNullish } from "@/utils/genUtils.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; -import { applyTrialToEntitlement } from "@/internal/products/entitlements/entitlementUtils.js"; -import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { getNextEntitlementReset } from "@/utils/timeUtils.js"; -import { subtractFromUnixTillAligned } from "@/internal/products/prices/billingIntervalUtils.js"; -import { UTCDate } from "@date-fns/utc"; import { entitlementLinkedToEntity } from "@/internal/api/entities/entityUtils.js"; import { initNextResetAt } from "../cusProducts/insertCusProduct/initCusEnt/initNextResetAt.js"; +import { Decimal } from "decimal.js"; export const initCusEntEntities = ({ entitlement, @@ -40,7 +35,7 @@ export const initCusEntEntities = ({ resetBalance?: number | null; }) => { let newEntities: Record | null = notNullish( - entitlement.entity_feature_id, + entitlement.entity_feature_id ) ? {} : null; @@ -108,74 +103,6 @@ const initCusEntBalance = ({ }); return { newBalance: resetBalance, newEntities }; - - // // 1. Get existing usage - // let { cusEnt, usage } = getExistingCusEntAndUsage({ - // entitlement, - // curCusProduct, - // relatedPrice, - // }); - - // Carry over entities3 - - // if ( - // !existingCusEnt || - // (!entitlement.carry_from_previous && !carryExistingUsages) - // ) { - // return { newBalance: resetBalance, newEntities }; - // } - - // let existingAllowanceType = existingCusEnt.entitlement.allowance_type; - // if ( - // nullish(existingCusEnt.balance) || - // existingAllowanceType === AllowanceType.Unlimited - // ) { - // return { newBalance: resetBalance, newEntities }; - // } - - // // Calculate existing usage - - // let curOptions = getEntOptions( - // curCusProduct?.options || [], - // existingCusEnt.entitlement - // ); - // let curPrice = getRelatedCusPrice( - // existingCusEnt, - // curCusProduct?.customer_prices || [] - // ); - - // let existingAllowance = getResetBalance({ - // entitlement: existingCusEnt.entitlement, - // options: curOptions, - // relatedPrice: curPrice?.price, - // }); - - // let existingUsage = existingAllowance! - existingCusEnt.balance!; - // let newBalance = resetBalance! - existingUsage; - - // if ( - // entitlement.entity_feature_id == - // existingCusEnt.entitlement.entity_feature_id - // ) { - // if (!newEntities) { - // newEntities = {}; - // } - - // for (const entityId in existingCusEnt.entities) { - // let existingBalance = existingCusEnt.entities[entityId].balance; - // let existingUsage = existingAllowance! - existingBalance; - - // let newBalance = resetBalance! - existingUsage; - - // newEntities[entityId] = { - // id: entityId, - // balance: newBalance, - // adjustment: 0, - // }; - // } - // } - - // return { newBalance, newEntities }; }; // MAIN FUNCTION @@ -196,6 +123,7 @@ export const initCusEntitlement = ({ curCusProduct, replaceables, now, + productOptions, }: { entitlement: EntitlementWithFeature; customer: Customer; @@ -213,6 +141,7 @@ export const initCusEntitlement = ({ curCusProduct?: FullCusProduct; replaceables: AttachReplaceable[]; now?: number; + productOptions?: ProductOptions; }) => { now = now || Date.now(); let { newBalance, newEntities } = initCusEntBalance({ @@ -251,6 +180,12 @@ export const initCusEntitlement = ({ usageAllowed = true; } + if (notNullish(productOptions?.quantity) && notNullish(newBalance)) { + newBalance = new Decimal(newBalance!) + .mul(productOptions?.quantity!) + .toNumber(); + } + return { id: generateId("cus_ent"), internal_customer_id: customer.internal_id, diff --git a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts new file mode 100644 index 000000000..634526481 --- /dev/null +++ b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts @@ -0,0 +1,153 @@ +import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; +import { + AttachParams, + AttachResultSchema, +} from "@/internal/customers/cusProducts/AttachParams.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import { + AttachBody, + AttachConfig, + AttachScenario, + CusProductStatus, + SuccessCode, +} from "@autumn/shared"; +import { getCustomerSub } from "../../attachUtils/convertAttachParams.js"; + +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; +import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; +import Stripe from "stripe"; +import { createStripeSub2 } from "../addProductFlow/createStripeSub2.js"; +import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import { attachToInvoiceResponse } from "@/internal/invoices/invoiceUtils.js"; + +export const handleMultiAttachFlow = async ({ + req, + res, + attachParams, + attachBody, + config, +}: { + req: ExtendedRequest; + res: ExtendedResponse; + attachParams: AttachParams; + attachBody: AttachBody; + config: AttachConfig; +}) => { + // 1. Get total sub items for subscription, cancel any schedule...? or what... + + // 2. Change cus product quantities (and customer entitlements...??) + + const { db, logger } = req; + const { stripeCli } = attachParams; + const productsList = attachParams.productsList!; + + const { sub } = await getCustomerSub({ attachParams }); + const itemSet = await getStripeSubItems2({ + attachParams, + config, + }); + + let finalSub: Stripe.Subscription | null = null; + + if (sub) { + const deleteCurSubItems = sub.items.data.map((item) => ({ + id: item.id, + deleted: true, + })); + + itemSet.subItems.push(...deleteCurSubItems); + + const { updatedSub } = await updateStripeSub2({ + req, + attachParams, + config, + curSub: sub, + itemSet, + fromCreate: true, + }); + + finalSub = updatedSub; + } else { + if (itemSet.subItems.length > 0) { + finalSub = await createStripeSub2({ + db, + stripeCli, + attachParams, + config, + itemSet, + }); + } + } + + // Expire all current cus products at the customer level + const batchExpire: any[] = []; + for (const cusProduct of attachParams.customer.customer_products) { + if (cusProduct.status == CusProductStatus.Scheduled) { + batchExpire.push( + CusProductService.delete({ + db, + cusProductId: cusProduct.id, + }) + ); + } else { + batchExpire.push( + CusProductService.update({ + db, + cusProductId: cusProduct.id, + updates: { + status: CusProductStatus.Expired, + }, + }) + ); + } + } + + // Expire all existing cus products at the customer level + const batchInsert: any[] = []; + for (const productOptions of productsList) { + const product = attachParams.products.find( + (p) => p.id === productOptions.product_id + )!; + + const anchorToUnix = finalSub + ? getLatestPeriodEnd({ sub: finalSub! }) * 1000 + : undefined; + + batchInsert.push( + createFullCusProduct({ + db, + attachParams: attachToInsertParams( + attachParams, + product, + productOptions.entity_id || undefined + ), + subscriptionIds: finalSub ? [finalSub?.id!] : undefined, + anchorToUnix, + scenario: AttachScenario.New, + logger, + productOptions, + }) + ); + } + + console.log("Running multi attach flow!"); + if (res) { + const invoice = finalSub?.latest_invoice as Stripe.Invoice; + res.status(200).json( + AttachResultSchema.parse( + AttachResultSchema.parse({ + message: `Successfully created subscriptions and attached ${attachParams.products.map((p) => p.name).join(", ")} to ${attachParams.customer.name}`, + code: SuccessCode.NewProductAttached, + product_ids: attachParams.products.map((p) => p.id), + customer_id: + attachParams.customer.id || attachParams.customer.internal_id, + invoice: attachParams.invoiceOnly + ? attachToInvoiceResponse({ invoice }) + : undefined, + }) + ) + ); + } +}; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 9d6c34309..697c7acb5 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -31,7 +31,7 @@ export const updateStripeSub2 = async ({ fromCreate?: boolean; }) => { const { db, logger } = req; - const { curMainProduct } = attachParamToCusProducts({ attachParams }); + const { stripeCli, customer, org, paymentMethod } = attachParams; const { invoiceOnly, proration } = config; @@ -87,6 +87,8 @@ export const updateStripeSub2 = async ({ }; } + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + // 2. Create prorations for single use items let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({ db, diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts index 787443db9..3ddec7f3d 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts @@ -34,7 +34,7 @@ export const priceToUnusedPreviewItem = ({ org?: Organization; }) => { now = now || Date.now(); - const onTrial = isTrialing({ cusProduct, now }); + const onTrial = isTrialing({ cusProduct }); // 1. Get price from stripe items const subItem = findStripeItemForPrice({ @@ -81,6 +81,10 @@ export const priceToUnusedPreviewItem = ({ }); description = `Unused ${description}`; + if (cusProduct.quantity && cusProduct.quantity > 1) { + description = `${description} x ${cusProduct.quantity}`; + } + if (finalProration) { description = `${description} (from ${formatUnixToDate(now)})`; } diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts index d138ab8b7..8dea49f7a 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts @@ -14,13 +14,20 @@ const getProductsForAttach = async ({ req: ExtendedRequest; attachBody: AttachBody; }) => { - const { product_id, product_ids, version } = attachBody; + const { + product_id, + product_ids, + version, + products: inputProducts, + } = attachBody; let products = await ProductService.listFull({ db: req.db, orgId: req.orgId, env: req.env, - inIds: product_ids || [product_id!], + inIds: inputProducts + ? inputProducts.map((p) => p.product_id) + : product_ids || [product_id!], version, }); diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index 587d6300f..8fe8e7a8b 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -72,6 +72,7 @@ export const getAttachParams = async ({ apiVersion, successUrl: attachBody.success_url, invoiceOnly: attachBody.invoice, + productsList: attachBody.products || undefined, // || attachBody.invoice_only billingAnchor: attachBody.billing_cycle_anchor, diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index b932b9c82..809e859cd 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -292,6 +292,11 @@ export const getAttachBranch = async ({ attachParams: AttachParams; fromPreview?: boolean; }) => { + if (notNullish(attachBody.products)) { + // 1. + return AttachBranch.MultiAttach; + } + // 1. Multi product if (notNullish(attachBody.product_ids)) { await handleMultiProductErrors({ attachParams }); diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index bddcd6d64..877322387 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -17,6 +17,7 @@ import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoi import { handleUpgradeFlow } from "../attachFunctions/upgradeFlow/handleUpgradeFlow.js"; import { handleScheduleFunction2 } from "../attachFunctions/scheduleFlow/handleScheduleFlow2.js"; import { handleRenewProduct } from "../attachFunctions/handleRenewProduct.js"; +import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMultiAttachFlow.js"; /* 1. If from new version, free trial should just carry over @@ -43,6 +44,7 @@ export const getAttachFunction = async ({ // 1. Checkout function const newScenario = [ + AttachBranch.MultiAttach, AttachBranch.MultiProduct, AttachBranch.OneOff, AttachBranch.New, @@ -55,6 +57,8 @@ export const getAttachFunction = async ({ return AttachFunction.CreateCheckout; } else if (branch == AttachBranch.OneOff) { return AttachFunction.OneOff; + } else if (branch == AttachBranch.MultiAttach) { + return AttachFunction.MultiAttach; } else if (newScenario) { return AttachFunction.AddProduct; } @@ -123,6 +127,7 @@ 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({ @@ -194,20 +199,15 @@ export const runAttachFunction = async ({ } } - // if (attachFunction == AttachFunction.Renew) { - // // Renew current subscription - - // res.status(200).json( - // AttachResultSchema.parse({ - // customer_id: - // attachParams.customer.id || attachParams.customer.internal_id, - // product_ids: attachParams.products.map((p) => p.id), - // code: SuccessCode.RenewedProduct, - // message: `Successfully renewed product ${attachParams.products[0].id}`, - // }) - // ); - // return; - // } + if (attachFunction == AttachFunction.MultiAttach) { + return await handleMultiAttachFlow({ + req, + res, + attachParams, + attachBody, + config, + }); + } if (attachFunction == AttachFunction.CreateCheckout) { if (config.invoiceCheckout) { diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 675075336..7e92aa015 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -27,6 +27,7 @@ import { import { findPriceForFeature } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; import { getResetBalance } from "../../cusProducts/cusEnts/cusEntUtils.js"; import { Decimal } from "decimal.js"; +import { handleMultiAttachErrors } from "./handleAttachErrors/handleMultiAttachErrors.js"; const handleNonCheckoutErrors = ({ flags, @@ -216,6 +217,14 @@ export const handleAttachErrors = async ({ }) => { const { onlyCheckout } = config; + if (branch === AttachBranch.MultiAttach) { + await handleMultiAttachErrors({ + attachParams, + attachBody, + }); + return; + } + // Invoice no payment enabled: onlyCheckout if (onlyCheckout || flags.isPublic) { diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts new file mode 100644 index 000000000..025dd21fa --- /dev/null +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleMultiAttachErrors.ts @@ -0,0 +1,36 @@ +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { + isContUsePrice, + isUsagePrice, +} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { AttachBody, Price } from "@autumn/shared"; + +export const handleMultiAttachErrors = async ({ + attachParams, + attachBody, +}: { + attachParams: AttachParams; + attachBody: AttachBody; +}) => { + const { products, prices } = attachParams; + + const usagePrice = prices.find( + (p: Price) => isUsagePrice({ price: p }) || isContUsePrice({ price: p }) + ); + + // 1. Don't support usage prices just yet... + if (usagePrice) { + const product = products.find( + (p) => p.internal_id === usagePrice.internal_product_id + ); + throw new RecaseError({ + code: "invalid_inputs", + message: `The 'products' parameter doesn't support prices that are pay per use at the moment. The product ${product?.name} contains this.`, + }); + } + + // 2. What if there are scheduled products...? (just replace?) + + // 3. +}; diff --git a/server/src/internal/customers/attach/checkout/handleCheckout.ts b/server/src/internal/customers/attach/checkout/handleCheckout.ts index ce78ce38e..e93e2fb60 100644 --- a/server/src/internal/customers/attach/checkout/handleCheckout.ts +++ b/server/src/internal/customers/attach/checkout/handleCheckout.ts @@ -1,9 +1,4 @@ -import { - AttachFunction, - AttachScenario, - CheckoutResponseSchema, - FeatureOptions, -} from "@autumn/shared"; +import { AttachFunction, FeatureOptions } from "@autumn/shared"; import { routeHandler } from "@/utils/routerUtils.js"; import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js"; @@ -20,7 +15,6 @@ import { } from "../attachRouter.js"; import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js"; import { previewToCheckoutRes } from "./previewToCheckoutRes.js"; -import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js"; import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; diff --git a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts index da9a50d72..c4e3bb6cb 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts @@ -1,6 +1,6 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { AttachBody } from "@autumn/shared"; +import { AttachBody, AttachBranch } from "@autumn/shared"; import { getAttachBranch } from "../attachUtils/getAttachBranch.js"; import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; import { AttachFunction } from "@autumn/shared"; @@ -10,6 +10,7 @@ import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js" import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js"; import { getNewProductPreview } from "./getNewProductPreview.js"; import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js"; +import { getMultiAttachPreview } from "./getMultiAttachPreview.js"; export const attachParamsToPreview = async ({ req, @@ -55,6 +56,16 @@ export const attachParamsToPreview = async ({ let preview: any = null; + if (branch == AttachBranch.MultiAttach) { + preview = await getMultiAttachPreview({ + req, + attachBody, + attachParams, + logger, + config, + }); + } + if ( func == AttachFunction.AddProduct || func == AttachFunction.CreateCheckout || diff --git a/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts new file mode 100644 index 000000000..0272f4216 --- /dev/null +++ b/server/src/internal/customers/attach/handleAttachPreview/getMultiAttachPreview.ts @@ -0,0 +1,61 @@ +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { AttachBody, PreviewLineItem } from "@autumn/shared"; +import { getCustomerSub } from "../attachUtils/convertAttachParams.js"; +import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { + cusProductsToPrices, + cusProductToPrices, +} from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { priceToUnusedPreviewItem } from "../attachPreviewUtils/priceToUnusedPreviewItem.js"; +import { handleMultiAttachErrors } from "../attachUtils/handleAttachErrors/handleMultiAttachErrors.js"; + +export const getMultiAttachPreview = async ({ + req, + attachBody, + attachParams, + logger, + config, +}: { + req: ExtendedRequest; + attachBody: AttachBody; + attachParams: AttachParams; + logger: any; + config: any; +}) => { + await handleMultiAttachErrors({ attachParams, attachBody }); + + const { customer } = attachParams; + const cusProducts = customer.customer_products; + const { sub } = await getCustomerSub({ attachParams }); + + let items: PreviewLineItem[] = []; + const subItems = sub?.items.data || []; + const prices = cusProductsToPrices({ cusProducts }); + + for (const price of prices) { + const previewLineItem = priceToUnusedPreviewItem({ + price, + stripeItems: subItems, + cusProduct: cusProducts[0], + }); + } + + // for (const cusProduct of cusProducts) { + // const prices = cusProductToPrices({ cusProduct }); + + // for (const price of prices) { + // const previewLineItem = priceToUnusedPreviewItem({ + // price, + // stripeItems: subItems, + // cusProduct, + // }); + + // if (!previewLineItem) continue; + + // items.push(previewLineItem); + // } + // } + + console.log("items: ", items); +}; diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 5dd392ab6..7c52c59fa 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -14,6 +14,7 @@ import { FullCustomer, AttachReplaceable, Reward, + ProductOptions, } from "@autumn/shared"; import Stripe from "stripe"; @@ -39,6 +40,7 @@ export type AttachParams = { freeTrial: FreeTrial | null; optionsList: FeatureOptions[]; + productsList?: ProductOptions[]; successUrl?: string | undefined; itemSets?: any[]; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts index f80dd28e2..08de21d29 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts @@ -8,7 +8,6 @@ import { FeatureType, FullCusEntWithFullCusProduct, FullCusProduct, - FullCustomerEntitlement, Price, } from "@autumn/shared"; @@ -131,6 +130,7 @@ export const getExistingUsages = ({ entitlement: ent, options, relatedPrice: relatedCusPrice?.price, + // productQuantity: curCusProduct.quantity, }); usages[key].usage += resetBalance! - cusEnt.balance!; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index b28121db3..ec9960ff5 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -18,6 +18,14 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { ACTIVE_STATUSES } from "../CusProductService.js"; +export const cusProductsToPrices = ({ + cusProducts, +}: { + cusProducts: FullCusProduct[]; +}) => { + return cusProducts.flatMap((cp) => cusProductToPrices({ cusProduct: cp })); +}; + export const cusProductsToCusPrices = ({ cusProducts, inStatuses, diff --git a/server/src/internal/customers/handlers/handleTransferProduct.ts b/server/src/internal/customers/handlers/handleTransferProduct.ts index d84dd7c8a..0fdc5ce03 100644 --- a/server/src/internal/customers/handlers/handleTransferProduct.ts +++ b/server/src/internal/customers/handlers/handleTransferProduct.ts @@ -6,8 +6,13 @@ import RecaseError from "@/utils/errorUtils.js"; import { ErrCode } from "@autumn/shared"; import { CusProductService } from "../cusProducts/CusProductService.js"; import { z } from "zod"; +import { nullish } from "@/utils/genUtils.js"; +import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; +import { cusProductToProduct } from "../cusProducts/cusProductUtils/convertCusProduct.js"; +import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; +import { ProductService } from "@/internal/products/ProductService.js"; const TransferProductSchema = z.object({ - from_entity_id: z.string(), + from_entity_id: z.string().nullish(), to_entity_id: z.string(), product_id: z.string(), }); @@ -31,6 +36,21 @@ export const handleTransferProduct = async (req: any, res: any) => // entityId: from_entity_id, }); + const product = await ProductService.get({ + id: product_id, + orgId: req.orgId, + env: req.env, + db: req.db, + }); + + if (!product) { + throw new RecaseError({ + code: ErrCode.ProductNotFound, + message: `Product ${product_id} not found`, + statusCode: 404, + }); + } + const fromEntity = customer.entities.find( (e: any) => e.id === from_entity_id ); @@ -39,13 +59,13 @@ export const handleTransferProduct = async (req: any, res: any) => (e: any) => e.id === to_entity_id ); - if (!fromEntity) { - throw new RecaseError({ - code: ErrCode.EntityNotFound, - message: `Entity ${from_entity_id} not found`, - statusCode: 404, - }); - } + // if (!fromEntity) { + // throw new RecaseError({ + // code: ErrCode.EntityNotFound, + // message: `Entity ${from_entity_id} not found`, + // statusCode: 404, + // }); + // } if (!toEntity) { throw new RecaseError({ @@ -57,10 +77,25 @@ export const handleTransferProduct = async (req: any, res: any) => const cusProduct = customer.customer_products.find( (cp: any) => - cp.internal_entity_id === fromEntity.internal_id && - cp.product.id === product_id + (fromEntity + ? cp.internal_entity_id === fromEntity.internal_id + : nullish(cp.internal_entity_id)) && cp.product.id === product_id ); + const toCusProduct = customer.customer_products.find( + (cp: any) => + cp.internal_entity_id === toEntity.internal_id && + cp.product.group === product.group + ); + + if (toCusProduct) { + throw new RecaseError({ + code: ErrCode.ProductAlreadyExists, + message: `Entity ${toEntity.id} already has product ${toCusProduct.product.name}`, + statusCode: 400, + }); + } + if (!cusProduct) { throw new RecaseError({ code: ErrCode.CusProductNotFound, @@ -69,38 +104,24 @@ export const handleTransferProduct = async (req: any, res: any) => }); } - // const cusProduct = customer.customer_products.find( - // (cp: any) => cp.id === customer_product_id - // ); - - // if (!cusProduct) { - // throw new RecaseError({ - // code: ErrCode.CusProductNotFound, - // message: "Customer product not found", - // statusCode: 404, - // }); - // } - - // let entity = customer.entities.find( - // (e: any) => e.internal_id === internal_entity_id - // ); - - // if (!entity) { - // throw new RecaseError({ - // code: ErrCode.EntityNotFound, - // message: "Entity not found", - // statusCode: 404, - // }); - // } - - await CusProductService.update({ - db: req.db, - cusProductId: cusProduct.id, - updates: { - entity_id: toEntity.id, - internal_entity_id: toEntity.internal_id, - }, - }); + // 1. If cus product has quantity > 1, only transfer 1... + if (cusProduct.quantity > 1) { + await handleDecreaseAndTransfer({ + req: req, + fullCus: customer, + cusProduct: cusProduct, + toEntity: toEntity, + }); + } else { + await CusProductService.update({ + db: req.db, + cusProductId: cusProduct.id, + updates: { + entity_id: toEntity.id, + internal_entity_id: toEntity.internal_id, + }, + }); + } res.status(200).json({ // message: "Product transferred successfully", diff --git a/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts new file mode 100644 index 000000000..07da4ebf3 --- /dev/null +++ b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts @@ -0,0 +1,102 @@ +import { + AttachScenario, + Entity, + ErrCode, + FeatureType, + FullCusProduct, + FullCustomer, +} from "@autumn/shared"; +import { createFullCusProduct } from "../../add-product/createFullCusProduct.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { + getRelatedCusPrice, + getResetBalance, +} from "../../cusProducts/cusEnts/cusEntUtils.js"; +import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js"; +import { CusProductService } from "../../cusProducts/CusProductService.js"; + +export const handleDecreaseAndTransfer = async ({ + req, + fullCus, + cusProduct, + toEntity, +}: { + req: ExtendedRequest; + fullCus: FullCustomer; + cusProduct: FullCusProduct; + toEntity: Entity; +}) => { + // 1. Create new cus product for entity... + const { org, env } = req; + const stripeCli = createStripeCli({ org, env }); + const product = cusProductToProduct({ cusProduct }); + + // Decrease quantity of cus product... + + const batchDecrement = []; + for (const cusEnt of cusProduct.customer_entitlements) { + const feature = cusEnt.entitlement.feature; + if (feature.type == FeatureType.Boolean) continue; + + const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices); + + const options = getEntOptions(cusProduct.options, cusEnt.entitlement); + const resetBalance = getResetBalance({ + entitlement: cusEnt.entitlement, + options: options, + relatedPrice: cusPrice?.price, + }); + + batchDecrement.push( + CusEntService.decrement({ + db: req.db, + id: cusEnt.id, + amount: resetBalance, + }) + ); + } + + await Promise.all(batchDecrement); + + await CusProductService.update({ + db: req.db, + cusProductId: cusProduct.id, + updates: { + quantity: cusProduct.quantity - 1, + }, + }); + + const newCusProduct = await createFullCusProduct({ + db: req.db, + logger: req.logger, + attachParams: attachToInsertParams( + { + req, + customer: fullCus, + products: [product], + prices: product.prices, + entitlements: product.entitlements, + org: req.org, + stripeCli: stripeCli, + paymentMethod: null, + freeTrial: null, + optionsList: cusProduct.options, + scenario: AttachScenario.New, + // scenario: AttachScenario.New, + cusProducts: fullCus.customer_products, + replaceables: [], + entities: fullCus.entities, + features: req.features, + internalEntityId: toEntity.internal_id, + entityId: toEntity.id, + }, + product + ), + sendWebhook: false, + }); +}; diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 72f320095..9ad8cf241 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -68,9 +68,11 @@ export const formatPrepaidPrice = ({ export const formatFixedPrice = ({ org, price, + quantity, }: { org: Organization; price: Price; + quantity?: number; }) => { const config = price.config as FixedPriceConfig; const amount = formatAmount({ org, amount: config.amount }); @@ -174,7 +176,11 @@ export const priceToInvoiceDescription = ({ billingType == BillingType.FixedCycle || billingType == BillingType.OneOff ) { - description = formatFixedPrice({ org: org!, price }); + description = formatFixedPrice({ + org: org!, + price, + quantity: cusProduct.quantity, + }); } if (billingType == BillingType.InArrearProrated) { diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index 29b01c240..96ea01dcd 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -17,7 +17,7 @@ import { formatAmount } from "@/utils/formatUtils.js"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js"; -import { isTrialing } from "@/internal/customers/cusProducts/cusProductUtils.js"; + import { isArrearPrice, isContUsePrice, diff --git a/server/src/internal/products/prices/priceUtils/convertPrice.ts b/server/src/internal/products/prices/priceUtils/convertPrice.ts index 89dec3499..7753d204a 100644 --- a/server/src/internal/products/prices/priceUtils/convertPrice.ts +++ b/server/src/internal/products/prices/priceUtils/convertPrice.ts @@ -8,6 +8,8 @@ import { CustomerPrice, FullCustomerEntitlement, FullCustomerPrice, + ProductOptions, + FullProduct, } from "@autumn/shared"; import { Price } from "@autumn/shared"; @@ -100,3 +102,22 @@ export const cusPriceToCusEnt = ({ (ce) => ce.entitlement?.id == cusPrice.price.entitlement_id ); }; + +export const priceToProductOptions = ({ + price, + options, + products, +}: { + price: Price; + options: ProductOptions[] | undefined; + products: FullProduct[]; +}) => { + if (!options) return undefined; + + const productId = products.find( + (p) => p.internal_id == price.internal_product_id + )?.id; + + const productOptions = options.find((o) => o.product_id == productId); + return productOptions; +}; diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 538cd7ea0..bbbb044f8 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -15,6 +15,7 @@ import { PriceType, ProcessorType, Product, + ProductOptions, ProductSchema, UsagePriceConfig, } from "@autumn/shared"; @@ -34,7 +35,7 @@ import { getEntsWithFeature, } from "./entitlements/entitlementUtils.js"; import { Decimal } from "decimal.js"; -import { generateId } from "@/utils/genUtils.js"; +import { generateId, notNullish } from "@/utils/genUtils.js"; import { PriceService } from "./prices/PriceService.js"; import { EntitlementService } from "./entitlements/EntitlementService.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -282,13 +283,27 @@ export const getPricesForProduct = (product: FullProduct, prices: Price[]) => { export const attachToInsertParams = ( attachParams: AttachParams, - product: FullProduct + product: FullProduct, + entityId?: string ) => { + // Get entity + let { internalEntityId, entityId: attachEntityId } = attachParams; + if (notNullish(entityId)) { + let entity = attachParams.customer.entities.find((e) => e.id === entityId); + + if (entity) { + internalEntityId = entity.internal_id; + attachEntityId = entity.id; + } + } + return { ...attachParams, product, prices: getPricesForProduct(product, attachParams.prices), entitlements: getEntitlementsForProduct(product, attachParams.entitlements), + entityId: attachEntityId, + internalEntityId: internalEntityId, } as InsertCusProductParams; }; diff --git a/server/tests/core/multiAttach/multiAttach1.test.ts b/server/tests/core/multiAttach/multiAttach1.test.ts new file mode 100644 index 000000000..3e803f66f --- /dev/null +++ b/server/tests/core/multiAttach/multiAttach1.test.ts @@ -0,0 +1,155 @@ +import chalk from "chalk"; +import { setupBefore } from "tests/before.js"; +import { Stripe } from "stripe"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + CusProductStatus, + Organization, +} from "@autumn/shared"; +import { + constructArrearItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { expect } from "chai"; +import { advanceToNextInvoice } from "tests/utils/testAttachUtils/testAttachUtils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { + advanceTestClock, + completeCheckoutForm, +} from "tests/utils/stripeUtils.js"; +import { addWeeks } from "date-fns"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; + +let growth = constructProduct({ + id: "growth", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), + ], + type: "growth", +}); + +let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 200 }), + ], + type: "premium", +}); + +let pro = constructProduct({ + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 300, + }), + ], + type: "pro", +}); + +const ops = [ + { + entityId: "1", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, + { + entityId: "2", + product: pro, + results: [{ product: pro, status: CusProductStatus.Active }], + }, +]; + +const testCase = "multiAttach1"; +describe(`${chalk.yellowBright("multiAttach1: Testing attach multiple products with diff quantities")}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + + let stripeCli: Stripe; + let testClockId: string; + let curUnix: number; + let db: DrizzleCli; + let org: Organization; + let env: AppEnv; + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, premium, growth], + prefix: testCase, + }); + + await createProducts({ + autumn: autumnJs, + products: [pro, premium, growth], + db, + orgId: org.id, + env, + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach multiple products with diff quantities", async function () { + const productsList = [ + { + product_id: pro.id, + quantity: 5, + }, + { + product_id: premium.id, + quantity: 3, + }, + ]; + + const { checkout_url } = await autumn.attach({ + customer_id: customerId, + // @ts-ignore + products: productsList, + force_checkout: true, + }); + + await completeCheckoutForm(checkout_url); + }); +}); diff --git a/shared/models/attachModels/attachBody.ts b/shared/models/attachModels/attachBody.ts index ce16f22a7..aeea288cd 100644 --- a/shared/models/attachModels/attachBody.ts +++ b/shared/models/attachModels/attachBody.ts @@ -4,6 +4,13 @@ import { ProductItemSchema } from "../productV2Models/productItemModels/productI import { CreateFreeTrialSchema } from "../productModels/freeTrialModels/freeTrialModels.js"; import { notNullish } from "../../utils/utils.js"; +export const ProductOptions = z.object({ + product_id: z.string(), + quantity: z.number().nullish(), + entity_id: z.string().nullish(), + options: z.array(FeatureOptionsSchema).nullish(), +}); + export const AttachBodySchema = z .object({ // Customer Info @@ -23,10 +30,10 @@ export const AttachBodySchema = z // Product Info product_id: z.string().nullish(), product_ids: z.array(z.string()).min(1).nullish(), - - // Options options: z.array(FeatureOptionsSchema).nullish(), + products: z.array(ProductOptions).nullish(), + // Custom Product is_custom: z.boolean().optional(), items: z.array(ProductItemSchema).optional(), @@ -49,7 +56,7 @@ export const AttachBodySchema = z }) .refine( (data) => { - if (!data.product_id && !data.product_ids) { + if (!data.product_id && !data.product_ids && !data.products) { return false; } @@ -112,3 +119,4 @@ export const AttachBodySchema = z ); export type AttachBody = z.infer; +export type ProductOptions = z.infer; diff --git a/shared/models/attachModels/attachEnums/AttachBranch.ts b/shared/models/attachModels/attachEnums/AttachBranch.ts index 2b86d6a28..8aa40b0d0 100644 --- a/shared/models/attachModels/attachEnums/AttachBranch.ts +++ b/shared/models/attachModels/attachEnums/AttachBranch.ts @@ -1,4 +1,5 @@ export enum AttachBranch { + MultiAttach = "multi_attach", // Done MultiProduct = "multi_product", OneOff = "one_off", diff --git a/shared/models/attachModels/attachEnums/AttachFunction.ts b/shared/models/attachModels/attachEnums/AttachFunction.ts index 47403eedf..01d8cb805 100644 --- a/shared/models/attachModels/attachEnums/AttachFunction.ts +++ b/shared/models/attachModels/attachEnums/AttachFunction.ts @@ -10,6 +10,7 @@ export enum AttachFunction { UpgradeSameInterval = "upgrade_same_interval", UpgradeDiffInterval = "upgrade_diff_interval", + MultiAttach = "multi_attach", } /* Handle checkout / public error: diff --git a/vite/src/index.css b/vite/src/index.css index f7c5fb152..8a2e7fa07 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -214,7 +214,7 @@ button:focus-visible { lg: ["17px", "24px"], xl: ["20px", "28px"], }, */ - --text-xs: 11px; + --text-xs: 12px; --text-sm: 13px; --text-md: 15px; --text-lg: 17px; diff --git a/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx b/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx index 19bea95b2..7c44c0f48 100644 --- a/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx +++ b/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx @@ -18,10 +18,14 @@ import { toast } from "sonner"; import { OrgService } from "@/services/OrgService"; import { CusProductStatus, Entity, Product } from "@autumn/shared"; import SmallSpinner from "@/components/general/SmallSpinner"; -import { Search } from "lucide-react"; +import { Blend, Search } from "lucide-react"; import { useOrg } from "@/hooks/useOrg"; -function AddProduct() { +function AddProduct({ + setMultiAttachOpen, +}: { + setMultiAttachOpen: (open: boolean) => void; +}) { const { products, customer, env, entityId, entities } = useCustomerContext(); const axiosInstance = useAxiosInstance({ env }); const [options, setOptions] = useState([]); @@ -73,7 +77,7 @@ function AddProduct() { entityId ? `?entity_id=${entityId}` : "" }`, navigate, - env, + env ); }; @@ -86,7 +90,7 @@ function AddProduct() {
@@ -115,6 +119,15 @@ function AddProduct() { /> )) )} + setMultiAttachOpen(true)} + > +
+ +

Multiple

+
+
diff --git a/vite/src/views/customers/customer/customer-product-list/CusProductToolbar.tsx b/vite/src/views/customers/customer/customer-product-list/CusProductToolbar.tsx index 2829e6526..bcde945e8 100644 --- a/vite/src/views/customers/customer/customer-product-list/CusProductToolbar.tsx +++ b/vite/src/views/customers/customer/customer-product-list/CusProductToolbar.tsx @@ -18,7 +18,7 @@ export const CusProductToolbar = ({ }: { cusProduct: FullCusProduct; }) => { - const { showEntityView } = useCustomerContext(); + const { showEntityView, customer } = useCustomerContext(); const [dialogOpen, setDialogOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); const [cancelOpen, setCancelOpen] = useState(false); @@ -39,7 +39,7 @@ export const CusProductToolbar = ({ - {showEntityView && ( + {(showEntityView || customer.entities.length > 0) && ( { diff --git a/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx b/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx index e149ea1e5..4864aa42c 100644 --- a/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx +++ b/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx @@ -40,6 +40,7 @@ import { import { CusProductStatusItem } from "../customer-product-list/CusProductStatus"; import { CusProductEntityItem } from "../components/CusProductEntityItem"; import { CusProductToolbar } from "./CusProductToolbar"; +import { MultiAttachDialog } from "../product/multi-attach/MultiAttachDialog"; export const CustomerProductList = ({ customer, @@ -53,6 +54,8 @@ export const CustomerProductList = ({ useCustomerContext(); const [showExpired, setShowExpired] = useState(false); + const [multiAttachOpen, setMultiAttachOpen] = useState(false); + const sortedProducts = customer.products .filter((p: CusProduct & { entitlements: any[] }) => { if (showExpired) { @@ -131,7 +134,13 @@ export const CustomerProductList = ({ Show Expired {/* */} - +
+ + +
@@ -187,6 +196,15 @@ export const CustomerProductList = ({ v{cusProduct.product.version} )} + + {cusProduct.quantity > 1 && ( + + x{cusProduct.quantity} + + )} diff --git a/vite/src/views/customers/customer/customer-product-list/TransferProductDialog.tsx b/vite/src/views/customers/customer/customer-product-list/TransferProductDialog.tsx index 8fed24f93..b553538ed 100644 --- a/vite/src/views/customers/customer/customer-product-list/TransferProductDialog.tsx +++ b/vite/src/views/customers/customer/customer-product-list/TransferProductDialog.tsx @@ -5,8 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogOverlay, - DialogPortal, } from "@/components/ui/dialog"; import { Select, @@ -16,7 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { FullCusProduct } from "@autumn/shared"; -import { ArrowLeftRight } from "lucide-react"; + import { useEffect, useState } from "react"; import { useCustomerContext } from "../CustomerContext"; import { useAxiosInstance } from "@/services/useAxiosInstance"; @@ -62,7 +60,7 @@ export const TransferProductDialog = ({ `/v1/customers/${cusProduct.customer_id}/transfer`, { // internal_entity_id: selectedEntity.internal_id, - from_entity_id: fromEntity.id, + from_entity_id: fromEntity?.id, to_entity_id: selectedEntity.id, product_id: cusProduct.product_id, // customer_product_id: cusProduct.id, diff --git a/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx b/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx new file mode 100644 index 000000000..d129f243b --- /dev/null +++ b/vite/src/views/customers/customer/product/multi-attach/MultiAttachDialog.tsx @@ -0,0 +1,311 @@ +import { + CustomDialogBody, + CustomDialogContent, + CustomDialogFooter, +} from "@/components/general/modal-components/DialogContentWrapper"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { MainDialogBodyWrapper } from "@/views/products/product/product-item/product-item-config/AdvancedConfigSidebar"; +import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { Minus, Plus, X } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useCustomerContext } from "../../CustomerContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState } from "react"; +import { FullProduct } from "@autumn/shared"; +import { Input } from "@/components/ui/input"; +import { formatAmount } from "@/utils/formatUtils/formatTextUtils"; +import { toast } from "sonner"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +export const MultiAttachDialog = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + const { customer, cusMutate, products, org } = useCustomerContext(); + + const axiosInstance = useAxiosInstance(); + + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [productOptions, setProductOptions] = useState([ + { product_id: null, quantity: 1 }, + ]); + + const defaultCurrency = org?.default_currency || "usd"; + + const handleAttachClicked = async ({ + // enableProductImmediately, + useInvoice, + setLoading, + }: { + // enableProductImmediately: false; + useInvoice: boolean; + setLoading: (loading: boolean) => void; + }) => { + console.log(productOptions); + for (const option of productOptions) { + if (!option.product_id) { + toast.error("Can't leave product empty"); + return; + } + } + + setLoading(true); + + try { + await axiosInstance.post("/v1/attach", { + customer_id: customer.id, + products: productOptions, + invoice: useInvoice, + enable_product_immediately: useInvoice ? true : undefined, + }); + await cusMutate(); + toast.success("Products attached successfully"); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to attach products")); + console.log(error); + } finally { + setLoading(false); + } + }; + + return ( + + + +
+ + Attach Products + + +
+ Products + Quantity + + {productOptions.map((option, index) => ( + <> + +
+ { + setProductOptions((prev) => { + const newOptions = [...prev]; + newOptions[index] = { + product_id: newOptions[index].product_id, + quantity: parseInt(e.target.value), + }; + return newOptions; + }); + }} + /> +
+ + ))} +
+ + +
+ Price +
+

Total:

+

+ {formatAmount({ + amount: 0, + currency: defaultCurrency, + })} +

+
+
+
+
+ + + + + + +
+
+ ); +}; + +{ + /*
+ Products + Quantity + + {productOptions.map((option, index) => ( + <> + +
+ { + setProductOptions((prev) => { + const newOptions = [...prev]; + newOptions[index] = { + product_id: newOptions[index].product_id, + quantity: parseInt(e.target.value), + }; + return newOptions; + }); + }} + /> +
+ + ))} +
*/ +}