diff --git a/bun.lock b/bun.lock index 6f9416854..9813fce2c 100644 --- a/bun.lock +++ b/bun.lock @@ -153,6 +153,7 @@ "version": "1.0.0", "dependencies": { "@date-fns/utc": "catalog:", + "@owpz/ksuid": "^25.7.20", "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", @@ -965,6 +966,8 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], + "@owpz/ksuid": ["@owpz/ksuid@25.7.20", "", { "dependencies": { "base-x": "^5.0.0" }, "bin": { "ksuid": "dist/cli.js" } }, "sha512-cTe0yLCXtKvVl7wE3E3r+oisL2vhrMy+OZaVN53gsPGc4oY7eyyobBb/MO7UUkYAbPjt+9n20eS/zudelQlVHA=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], @@ -1699,6 +1702,8 @@ "base-convert-int-array": ["base-convert-int-array@1.0.1", "", {}, "sha512-NWqzaoXx8L/SS32R+WmKqnQkVXVYl2PwNJ68QV3RAlRRL1uV+yxJT66abXI1cAvqCXQTyXr7/9NN4Af90/zDVw=="], + "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -3191,6 +3196,8 @@ "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], + "@autumn/shared/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -3963,6 +3970,8 @@ "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 63f103d17..2b0295162 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -24,3 +24,4 @@ BUN_PARALLEL_COMPACT \ 'server/tests/balances/track/concurrency' \ 'server/tests/balances/track/negative' \ 'server/tests/balances/update' \ + diff --git a/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts b/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts deleted file mode 100644 index 286c53ffe..000000000 --- a/server/src/external/stripe/stripeInvoiceUtils/convertStripeInvoiceLineUtils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type Stripe from "stripe"; - -export const stripeInvoiceLineItemToPriceId = ( - lineItem: Stripe.InvoiceLineItem, -) => { - const priceId = lineItem.pricing?.price_details?.price; - if (!priceId) { - return undefined; - } - - if (typeof priceId !== "string") { - throw new Error("lineItem.pricing.price_details.price is not a string"); - } - - return priceId; -}; - -export const stripeInvoiceLineItemToProductId = ( - lineItem: Stripe.InvoiceLineItem, -) => { - const productId = lineItem.pricing?.price_details?.product; - if (!productId) { - return undefined; - } - - if (typeof productId !== "string") { - throw new Error("lineItem.pricing.price_details.product is not a string"); - } - - return productId; -}; diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 12087e9e8..27fa5c56d 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -222,7 +222,7 @@ export const getStripeSchedules = async ({ }, ); - if (schedule.status == "canceled") { + if (schedule.status === "canceled") { return null; } diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils.ts b/server/src/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils.ts new file mode 100644 index 000000000..a57181a9a --- /dev/null +++ b/server/src/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils.ts @@ -0,0 +1,13 @@ +import type Stripe from "stripe"; + +export const stripeSubscriptionItemToStripePriceId = ( + stripeSubscriptionItem: Stripe.SubscriptionItem, +) => { + const price = stripeSubscriptionItem.price; + + if (typeof price === "string") { + return price; + } + + return price.id; +}; diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils.ts b/server/src/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils.ts new file mode 100644 index 000000000..51b3a6eb9 --- /dev/null +++ b/server/src/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils.ts @@ -0,0 +1,14 @@ +import type Stripe from "stripe"; +import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils"; + +export const findStripeSubscriptionItemByStripePriceId = ({ + stripePriceId, + stripeSubscriptionItems, +}: { + stripePriceId: string; + stripeSubscriptionItems: Stripe.SubscriptionItem[]; +}) => { + return stripeSubscriptionItems.find( + (item) => stripeSubscriptionItemToStripePriceId(item) === stripePriceId, + ); +}; diff --git a/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts new file mode 100644 index 000000000..ad75519ca --- /dev/null +++ b/server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts @@ -0,0 +1,22 @@ +import { notNullish } from "@autumn/shared"; +import type Stripe from "stripe"; + +export const isStripeSubscriptionTrialing = ( + stripeSubscription: Stripe.Subscription, +) => { + return stripeSubscription.status === "trialing"; +}; + +export const isStripeSubscriptionCancelling = ( + stripeSubscription?: Stripe.Subscription, +) => { + if (!stripeSubscription) { + return false; + } + + return ( + notNullish(stripeSubscription.canceled_at) || + notNullish(stripeSubscription.cancel_at) || + stripeSubscription.cancel_at_period_end + ); +}; diff --git a/server/src/internal/billing/billingUtils/executeScheduledCusProductAction.ts b/server/src/internal/billing/billingUtils/executeScheduledCusProductAction.ts deleted file mode 100644 index be811e1a2..000000000 --- a/server/src/internal/billing/billingUtils/executeScheduledCusProductAction.ts +++ /dev/null @@ -1,22 +0,0 @@ -// import type { ScheduledCusProductAction } from "@autumn/shared"; -// import type { AutumnContext } from "../../../honoUtils/HonoEnv"; -// import { CusProductService } from "../../customers/cusProducts/CusProductService"; - -// export const executeScheduledCusProductAction = async ({ -// ctx, -// scheduledCusProductAction, -// }: { -// ctx: AutumnContext; -// scheduledCusProductAction?: ScheduledCusProductAction; -// }) => { -// if (!scheduledCusProductAction) return; - -// const { action, cusProduct } = scheduledCusProductAction; - -// if (action === "delete") { -// return await CusProductService.delete({ -// db: ctx.db, -// cusProductId: cusProduct.id, -// }); -// } -// }; diff --git a/server/src/internal/billing/billingUtils/getAttachSub.ts b/server/src/internal/billing/billingUtils/getAttachSub.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementEntities.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementEntities.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementNextResetAt.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementNextResetAt.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementUsageAllowed.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementUsageAllowed.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusPrice.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerPrice.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusPrice.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerPrice.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCustomerProduct.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerProduct.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCustomerProduct.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initCustomerProduct.ts diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts b/server/src/internal/billing/billingUtils/initFullCustomerProduct/initFullCustomerProduct.ts similarity index 100% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts rename to server/src/internal/billing/billingUtils/initFullCustomerProduct/initFullCustomerProduct.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts b/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts deleted file mode 100644 index 9f1d7a721..000000000 --- a/server/src/internal/billing/billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - type FullCusProduct, - type FullCustomer, - filterCusProductsBySubId, - isConsumablePrice, - isCusProductOngoing, - type StripeItemSpec, -} from "@autumn/shared"; -import type Stripe from "stripe"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { cusProductToStripeItemSpecs } from "../cusProductToStripeItemSpecs"; - -/** - * Initialize targetItems with current sub state. - * - Regular items: set quantity - * - Metered items: set undefined (no quantity) - */ -const initializeTargetItems = ({ - currentItems, -}: { - currentItems: Stripe.SubscriptionItem[]; -}): Map => { - const targetItems = new Map(); - - for (const item of currentItems) { - const priceId = item.price?.id; - if (!priceId) continue; - - // Metered items have no quantity (or quantity is irrelevant) - const isMetered = item.price?.recurring?.usage_type === "metered"; - targetItems.set(priceId, isMetered ? undefined : (item.quantity ?? 1)); - } - - return targetItems; -}; - -/** - * Adds new items to targetItems map. - * - Consumable prices: only add if not already in map, use spec.quantity (0 or undefined) - * - Regular prices: add to existing quantity in map - */ -const addNewItems = ({ - targetItems, - itemsToAdd, -}: { - targetItems: Map; - itemsToAdd: StripeItemSpec[]; -}) => { - for (const spec of itemsToAdd) { - const isConsumable = - spec.autumnPrice && isConsumablePrice(spec.autumnPrice); - - // CONSUMABLE: Only add if not already in map, use quantity from spec - if (isConsumable) { - if (targetItems.has(spec.stripePriceId)) continue; - targetItems.set(spec.stripePriceId, spec.quantity); // Could be 0 or undefined - continue; - } - - // REGULAR: Add to existing quantity in map - const existingQty = targetItems.get(spec.stripePriceId) ?? 0; - const newQty = (existingQty ?? 0) + (spec.quantity ?? 1); - targetItems.set(spec.stripePriceId, newQty); - } -}; - -/** - * Check if a cusProduct has a specific stripe price ID - */ -const cusProductHasStripePriceId = ({ - cusProduct, - stripePriceId, -}: { - cusProduct: FullCusProduct; - stripePriceId: string; -}): boolean => { - return cusProduct.customer_prices.some( - (cp) => - cp.price.config.stripe_price_id === stripePriceId || - cp.price.config.stripe_empty_price_id === stripePriceId, - ); -}; - -/** - * Removes old items from targetItems map. - * - Consumable: keep if ANY remaining cusProduct needs it, otherwise delete - * - Regular: always subtract quantity, delete if <= 0 - */ -const removeOldItems = ({ - targetItems, - itemsToRemove, - remainingCusProducts, -}: { - targetItems: Map; - itemsToRemove: StripeItemSpec[]; - remainingCusProducts: FullCusProduct[]; // All cus products AFTER operation (includes new, excludes old) -}) => { - for (const spec of itemsToRemove) { - const priceId = spec.stripePriceId; - const isConsumable = - spec.autumnPrice && isConsumablePrice(spec.autumnPrice); - - // CONSUMABLE: Keep if ANY remaining cusProduct needs it - if (isConsumable) { - const anyNeedsIt = remainingCusProducts.some((cp) => - cusProductHasStripePriceId({ cusProduct: cp, stripePriceId: priceId }), - ); - if (anyNeedsIt) continue; - - // No one needs it, delete - targetItems.delete(priceId); - continue; - } - - // REGULAR: Always subtract quantity - const existingQty = targetItems.get(priceId) ?? 0; - const quantityToRemove = spec.quantity ?? 1; - const newQty = (existingQty ?? 0) - quantityToRemove; - - if (newQty <= 0) { - targetItems.delete(priceId); - } else { - targetItems.set(priceId, newQty); - } - } -}; - -/** - * Convert targetItems map to Stripe subscription update params. - * Compares with currentItems to determine add/update/delete operations. - */ -const toStripeParams = ({ - targetItems, - currentItems, -}: { - targetItems: Map; - currentItems: Stripe.SubscriptionItem[]; -}): Stripe.SubscriptionUpdateParams.Item[] => { - const result: Stripe.SubscriptionUpdateParams.Item[] = []; - - // Handle additions and updates - for (const [priceId, quantity] of targetItems) { - const existingItem = currentItems.find((si) => si.price?.id === priceId); - - if (existingItem) { - // UPDATE existing item (only if quantity changed) - const currentQty = existingItem.quantity; - if (quantity !== currentQty) { - result.push({ id: existingItem.id, quantity }); - } - } else { - // ADD new item - result.push({ price: priceId, quantity }); - } - } - - // Handle deletions - items in current but NOT in target - for (const item of currentItems) { - const priceId = item.price?.id; - if (!priceId) continue; - - if (!targetItems.has(priceId)) { - result.push({ id: item.id, deleted: true }); - } - } - - return result; -}; - -export const buildSubItemUpdate = ({ - ctx, - fullCus, - stripeSub, - paymentMethod, - ongoingCusProduct, - newCusProducts, -}: { - ctx: AutumnContext; - fullCus: FullCustomer; - stripeSub: Stripe.Subscription; - paymentMethod?: Stripe.PaymentMethod; - ongoingCusProduct?: FullCusProduct; - newCusProducts?: FullCusProduct[]; -}) => { - // const { fullCus, stripeSub } = attachContext; - const currentItems = stripeSub?.items.data || []; - - const itemsToAdd = - newCusProducts?.flatMap((cusProduct) => - cusProductToStripeItemSpecs({ - ctx, - cusProduct, - fromVercel: paymentMethod?.type === "custom", - }), - ) ?? []; - - const itemsToRemove = ongoingCusProduct - ? cusProductToStripeItemSpecs({ - ctx, - cusProduct: ongoingCusProduct, - fromVercel: paymentMethod?.type === "custom", // TODO: - }) - : []; - - // Cus products that will remain after operation (excludes old, includes existing + new) - const existingCusProducts = filterCusProductsBySubId({ - cusProducts: fullCus.customer_products, - subId: stripeSub?.id, - }) - .filter((cp: FullCusProduct) => cp.id !== ongoingCusProduct?.id) - .filter((cp: FullCusProduct) => isCusProductOngoing({ cusProduct: cp })); - - const remainingCusProducts = [ - ...existingCusProducts, - ...(newCusProducts ?? []), - ]; - - // Step 0: Initialize targetItems with current sub state - const targetItems = initializeTargetItems({ currentItems }); - - // Step 1: Add new items - addNewItems({ targetItems, itemsToAdd }); - - // Step 2: Remove old items - removeOldItems({ - targetItems, - itemsToRemove, - remainingCusProducts, - }); - - // Step 3: Convert to Stripe params (deletions derived from diff) - const finalSubItems = toStripeParams({ targetItems, currentItems }); - - return finalSubItems; -}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToStripeItemSpecs.ts b/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToStripeItemSpecs.ts deleted file mode 100644 index efa2729cb..000000000 --- a/server/src/internal/billing/billingUtils/stripeAdapter/cusProductToStripeItemSpecs.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - addCusProductToCusEnt, - type BillingInterval, - cusPriceToCusEnt, - cusProductToProduct, - entToOptions, - type FeatureOptions, - type FullCusProduct, - intervalsSame, - isAllocatedCusEnt, - notNullish, - type StripeItemSpec, -} from "@autumn/shared"; -import { cusEntToInvoiceUsage } from "../../../../../../shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage"; -import { priceToStripeItem } from "../../../../external/stripe/priceToStripeItem/priceToStripeItem"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; - -export const cusProductToStripeItemSpecs = ({ - ctx, - cusProduct, - fromCheckout = false, - fromVercel = false, - - // filter by interval - interval, - intervalCount = 1, -}: { - ctx: AutumnContext; - cusProduct: FullCusProduct; - fromCheckout?: boolean; - fromVercel?: boolean; - - // filter by interval - interval?: BillingInterval; - intervalCount?: number; -}) => { - const product = cusProductToProduct({ cusProduct }); - - const cusPrices = cusProduct.customer_prices; - const cusEnts = cusProduct.customer_entitlements; - // const fromVercel = attachContext?.paymentMethod?.type === "custom"; - - const { org } = ctx; - - const stripeItems: StripeItemSpec[] = []; - - for (const cusPrice of cusPrices) { - const price = cusPrice.price; - const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts }); - const ent = cusEnt?.entitlement; - - if ( - interval && - !intervalsSame({ - intervalA: { interval, intervalCount }, - intervalB: price.config, - }) - ) { - continue; - } - - let options: FeatureOptions | undefined; - let existingUsage: number | undefined; - if (cusEnt) { - const ent = cusEnt.entitlement; - options = entToOptions({ ent, options: cusProduct.options ?? [] }); - - const cusEntWithCusProduct = addCusProductToCusEnt({ - cusEnt, - cusProduct, - }); - - if (isAllocatedCusEnt(cusEntWithCusProduct)) { - existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct }); - } - } - - const stripeItem = priceToStripeItem({ - price, - product, - org, - options, - isCheckout: fromCheckout, - relatedEnt: ent, - existingUsage, - withEntity: notNullish(cusProduct.internal_entity_id), - apiVersion: ctx.apiVersion.value, - fromVercel, - }); - - if (!stripeItem) { - continue; - } - - const { lineItem } = stripeItem; - - // subItems.push(lineItem); - stripeItems.push({ - stripePriceId: lineItem?.price ?? "", - quantity: lineItem?.quantity, - autumnPrice: price, - }); - } - - return stripeItems; -}; - -// const { -// prices, -// entitlements, -// optionsList, -// cusProducts, -// customer, -// internalEntityId, -// products, -// } = attachParams; - -// const subItems: any[] = []; -// const invoiceItems: any[] = []; -// const usageFeatures: any[] = []; -// 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, -// cusProducts, -// entities: customer.entities ?? [], -// carryExistingUsages: config.carryUsage, -// internalEntityId, -// }); - -// const replaceables = priceEnt -// ? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id) -// : []; - -// existingUsage += replaceables.length; - -// const product = getProductForPrice(price, attachParams.products)!; - -// if (!product) { -// logger.error( -// `Couldn't find product for price ${price.internal_product_id}`, -// { -// data: { -// products: attachParams.products, -// price, -// }, -// }, -// ); -// throw new InternalError({ -// message: `Price internal product ID: ${price.internal_product_id} not found in products`, -// }); -// } - -// const stripeItem = priceToStripeItem({ -// price, -// product, -// org: attachParams.org, -// options, -// isCheckout: config.onlyCheckout, -// relatedEnt: priceEnt, -// existingUsage, -// withEntity: notNullish(internalEntityId), -// apiVersion: attachParams.apiVersion, -// productOptions: prodOptions, -// fromVercel: attachParams.paymentMethod?.type === "custom", -// }); - -// if (isUsagePrice({ price })) { -// usageFeatures.push(priceEnt.feature.internal_id); -// } - -// if (!stripeItem) { -// continue; -// } - -// const { lineItem } = stripeItem; - -// // subItems.push(lineItem); - -// if (price.config.interval === BillingInterval.OneOff) { -// invoiceItems.push(lineItem); -// } else { -// subItems.push({ -// ...lineItem, -// autumnPrice: price, -// }); -// } -// } - -// return { subItems, invoiceItems, usageFeatures } as ItemSet; -// if (price.config.interval === BillingInterval.OneOff) { -// invoiceItems.push({ -// stripe_price_id: lineItem.price, -// quantity: lineItem.quantity, -// }); -// } else { -// subItems.push({ -// ...lineItem, -// autumnPrice: price, -// }); -// } diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeSubOps/updateStripeSub.ts b/server/src/internal/billing/billingUtils/stripeAdapter/stripeSubOps/updateStripeSub.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/internal/billing/v2/FOLDER_STRUCTURE.md b/server/src/internal/billing/v2/FOLDER_STRUCTURE.md index e626e9b2e..529458298 100644 --- a/server/src/internal/billing/v2/FOLDER_STRUCTURE.md +++ b/server/src/internal/billing/v2/FOLDER_STRUCTURE.md @@ -19,15 +19,15 @@ server/src/internal/billing/v2/ │ ├── compute/ # Shared compute helpers (server-side) │ │ │ │ │ ├── cusProduct/ # CusProduct initialization -│ │ │ ├── initFullCusProduct.ts -│ │ │ ├── initCusProduct.ts -│ │ │ ├── initCusPrice.ts -│ │ │ ├── initCusEntitlement/ -│ │ │ │ ├── initCusEntitlement.ts -│ │ │ │ ├── initCusEntitlementBalance.ts -│ │ │ │ ├── initCusEntitlementEntities.ts -│ │ │ │ ├── initCusEntitlementNextResetAt.ts -│ │ │ │ └── initCusEntUsageAllowed.ts +│ │ │ ├── initFullCustomerProduct.ts +│ │ │ ├── initCustomerProduct.ts +│ │ │ ├── initCustomerPrice.ts +│ │ │ ├── initCustomerEntitlement/ +│ │ │ │ ├── initCustomerEntitlement.ts +│ │ │ │ ├── initCustomerEntitlementBalance.ts +│ │ │ │ ├── initCustomerEntitlementEntities.ts +│ │ │ │ ├── initCustomerEntitlementNextResetAt.ts +│ │ │ │ └── initCustomerEntitlementUsageAllowed.ts │ │ │ └── index.ts │ │ │ │ │ ├── existingState/ # Handle existing usages/rollovers @@ -330,7 +330,7 @@ export const handleAttach = createRoute({ | `billingUtils/stripeAdapter/buildSubItems/*` | `stripe/subItems/*` | | `billingUtils/stripeAdapter/stripeInvoiceOps/*` | `stripe/invoice/*` | | `billingUtils/stripeAdapter/applyStripeDiscounts/*` | `stripe/discounts/*` | -| `billingUtils/initFullCusProduct/*` | `cusProduct/*` | +| `billingUtils/initFullCustomerProduct/*` | `cusProduct/*` | **Note**: `shared/utils/billingUtils/` stays in place - it's pure calculations used by frontend too. diff --git a/server/src/internal/billing/v2/REFACTOR_PLAN.md b/server/src/internal/billing/v2/REFACTOR_PLAN.md index cde8958a5..ded420192 100644 --- a/server/src/internal/billing/v2/REFACTOR_PLAN.md +++ b/server/src/internal/billing/v2/REFACTOR_PLAN.md @@ -107,7 +107,7 @@ Create `computeUpdatePlanPlan.ts`: **Inputs**: Target cus product, new product definition **Compute**: -1. Build new cus product (reuse [`initFullCusProduct`](server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts)) +1. Build new cus product (reuse [`initFullCustomerProduct`](server/src/internal/billing/billingUtils/initFullCustomerProduct/initFullCustomerProduct.ts)) 2. Build `stripe.subscription` update params 3. Build `autumn.expireCusProducts` for old cus product 4. Build `autumn.insertCusProducts` for new cus product diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts new file mode 100644 index 000000000..928993511 --- /dev/null +++ b/server/src/internal/billing/v2/billingContext.ts @@ -0,0 +1,11 @@ +import type { FullCustomer } from "@shared/models/cusModels/fullCusModel"; +import type Stripe from "stripe"; + +export interface BillingContext { + fullCustomer: FullCustomer; + stripeCustomer: Stripe.Customer; + + stripeSubscription?: Stripe.Subscription; + paymentMethod?: Stripe.PaymentMethod; + testClockFrozenTime?: number; +} diff --git a/server/src/internal/billing/v2/billingPlan.ts b/server/src/internal/billing/v2/billingPlan.ts index 10802e8cb..3d42b473d 100644 --- a/server/src/internal/billing/v2/billingPlan.ts +++ b/server/src/internal/billing/v2/billingPlan.ts @@ -1,38 +1,56 @@ -import { EntitlementSchema, PriceSchema } from "@autumn/shared"; +import { + CusProductStatus, + EntitlementSchema, + FreeTrialSchema, + PriceSchema, +} from "@autumn/shared"; import { z } from "zod/v4"; import { FullCusProductSchema } from "../../../../../shared/models/cusProductModels/cusProductModels"; -// manualInvoice?: { -// items: Stripe.InvoiceItemCreateParams[]; -// finalize: boolean; -// chargeAutomatically: boolean; -// }; -// subscription?: { -// action: "create" | "update" | "cancel"; -// params: SubscriptionParams; -// }; -// subscriptionItemUpdates?: { itemId: string; quantity: number }[]; -// checkout?: Stripe.Checkout.SessionCreateParams; +export const FreeTrialPlanSchema = z.object({ + freeTrial: FreeTrialSchema.nullable().optional(), + trialEndsAt: z.number().optional(), +}); + +export type FreeTrialPlan = z.infer; + +export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("create"), + params: z.custom(), + }), + z.object({ + type: z.literal("update"), + stripeSubscriptionId: z.string(), + params: z.custom(), + }), + z.object({ + type: z.literal("cancel"), + stripeSubscriptionId: z.string(), + }), +]); + +export type StripeSubscriptionAction = z.infer< + typeof StripeSubscriptionActionSchema +>; export const StripeBillingPlanSchema = z.object({ - subscription: z.object({ - action: z.enum(["create", "update", "cancel"]), - params: z.object({ - items: z.array(z.object({ id: z.string(), quantity: z.number() })), - }), - }), + subscription: StripeSubscriptionActionSchema.optional(), }); export const AutumnBillingPlanSchema = z.object({ - insertCusProducts: z.array(FullCusProductSchema), + insertCustomerProducts: z.array(FullCusProductSchema), - updateCusProduct: z.object({ - cusProductId: z.string(), - action: z.enum(["expire"]), + updateCustomerProduct: z.object({ + customerProduct: FullCusProductSchema, + updates: z.object({ + status: z.enum(CusProductStatus), + }), }), - insertCustomPrices: z.array(PriceSchema), - insertCustomEntitlements: z.array(EntitlementSchema), + customPrices: z.array(PriceSchema), // Custom prices to insert + customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert + customFreeTrial: FreeTrialSchema.optional(), // Custom free trial to insert // expireCusProducts: z.array(z.string()), @@ -46,11 +64,9 @@ export const AutumnBillingPlanSchema = z.object({ }); export const BillingPlanSchema = z.object({ - intent: z.enum(["update_quantity", "update_plan"]), - featureQuantities: z.array( - z.object({ - featureId: z.string(), - quantity: z.number(), - }), - ), + autumn: AutumnBillingPlanSchema, + stripe: StripeBillingPlanSchema, }); + +export type BillingPlan = z.infer; +export type AutumnBillingPlan = z.infer; diff --git a/server/src/internal/billing/v2/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/compute/computeAttachPlan.ts index 0cc48a89e..7d26a0761 100644 --- a/server/src/internal/billing/v2/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/compute/computeAttachPlan.ts @@ -3,9 +3,6 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import type { AttachContext } from "../typesOld"; import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems"; import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts"; -import { buildStripeCheckoutAction } from "./computeStripeUtils/buildStripeCheckoutAction"; -import { buildStripeInvoiceAction } from "./computeStripeUtils/buildStripeInvoiceAction"; -import { buildStripeSubAction } from "./computeStripeUtils/buildStripeSubAction"; /** * Shared logic by attach, cancel and @@ -41,7 +38,7 @@ export const computeAttachPlan = async ({ const autumnLineItems = buildAutumnLineItems({ ctx, newCusProducts, - ongoingCusProductAction, + ongoingCustomerProduct: ongoingCusProductAction?.cusProduct, billingCycleAnchor, testClockFrozenTime, }); @@ -53,30 +50,16 @@ export const computeAttachPlan = async ({ // newCusProducts, // }); - // 4. Build stripe checkout action - const stripeCheckoutAction = buildStripeCheckoutAction({ - ctx, - attachContext, - newCusProducts, - }); - // 5. Build stripe sub action - const stripeSubAction = buildStripeSubAction({ - ctx, - stripeSub: attachContext.stripeSub!, - fullCus: attachContext.fullCus, - paymentMethod: attachContext.paymentMethod, - ongoingCusProductAction, - newCusProducts, - }); + const stripeSubAction = undefined; // 6. Build stripe invoice action - const stripeInvoiceAction = buildStripeInvoiceAction({ - attachContext, - autumnLineItems, - stripeSubAction, - newCusProducts, - }); + // const stripeInvoiceAction = buildStripeInvoiceAction({ + // attachContext, + // autumnLineItems, + // stripeSubAction, + // newCusProducts, + // }); return { autumnLineItems, @@ -86,7 +69,5 @@ export const computeAttachPlan = async ({ newCusProducts, stripeSubAction, - stripeInvoiceAction, - stripeCheckoutAction, }; }; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts index dae93d922..623e9ddfd 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts @@ -1,6 +1,6 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { cusProductToExistingUsages } from "../../../billingUtils/handleExistingUsages/cusProductToExistingUsages"; -import { initFullCustomerProduct } from "../../../billingUtils/initFullCusProduct/initFullCustomerProduct"; +import { cusProductToExistingUsages } from "../../utils/handleExistingUsages/cusProductToExistingUsages"; +import { initFullCustomerProduct } from "../../utils/initFullCustomerProduct/initFullCustomerProduct"; import type { AttachContext } from "../../typesOld"; export const buildNewCusProducts = ({ @@ -28,7 +28,6 @@ export const buildNewCusProducts = ({ fullCustomer: fullCus, fullProduct: products[0], featureQuantities: [], - replaceables: [], existingUsages, }, }); diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts deleted file mode 100644 index 162250509..000000000 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - BillingInterval, - cusProductsToPrices, - type FullCusProduct, - getLargestInterval, - msToSeconds, -} from "@autumn/shared"; -import type Stripe from "stripe"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { toSuccessUrl } from "../../../../orgs/orgUtils/convertOrgUtils"; -import { cusProductToStripeItemSpecs } from "../../../billingUtils/stripeAdapter/cusProductToStripeItemSpecs"; -import type { AttachContext } from "../../typesOld"; -import { computeShouldCreateStripeCheckout } from "./computeShouldCreateStripeCheckout"; - -export const buildCheckoutSessionCreateSubscriptionData = ({ - isRecurring, - trialEndsAt, - // freeTrial, - // billingCycleAnchorUnixSeconds, -}: { - isRecurring: boolean; - trialEndsAt?: number; - // freeTrial: FreeTrial; - // billingCycleAnchorUnixSeconds: number; -}): Stripe.Checkout.SessionCreateParams.SubscriptionData | undefined => { - if (isRecurring && trialEndsAt) { - return { - trial_end: msToSeconds(trialEndsAt), - trial_settings: { - end_behavior: { - missing_payment_method: "cancel", - }, - }, - }; - } - - return undefined; -}; - -export const buildStripeCheckoutAction = ({ - ctx, - attachContext, - newCusProducts, -}: { - ctx: AutumnContext; - attachContext: AttachContext; - newCusProducts: FullCusProduct[]; -}) => { - const { org, env } = ctx; - const { body } = attachContext; - - const { shouldCreate, reason } = computeShouldCreateStripeCheckout({ - attachContext, - newCusProducts, - }); - - // 1. Get largest interval - const largestInterval = getLargestInterval({ - prices: cusProductsToPrices({ cusProducts: newCusProducts }), - }); - - // 2. Get params - const stripeItemSpecs = newCusProducts.flatMap((cusProduct) => - cusProductToStripeItemSpecs({ - ctx, - cusProduct, - fromCheckout: true, - fromVercel: attachContext.paymentMethod?.type === "custom", - interval: largestInterval?.interval, - intervalCount: largestInterval?.intervalCount, - }), - ); - - // 3. Is recurring: - const isRecurring = largestInterval?.interval !== BillingInterval.OneOff; - const subscriptionData = buildCheckoutSessionCreateSubscriptionData({ - isRecurring, - trialEndsAt: undefined, // TODO: - }); - - const metadata: Record = { - ...(body.checkout_session_params?.metadata || {}), - }; - - // 4. Build checkout session params - const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { - success_url: toSuccessUrl({ org, env }), - subscription_data: subscriptionData, - mode: isRecurring ? "subscription" : "payment", - line_items: stripeItemSpecs.map((item) => ({ - price: item.stripePriceId, - quantity: item.quantity, - })), - metadata, - }; - - return { - shouldCreate, - reason, - params: checkoutSessionCreateParams, - }; -}; - -// let billingCycleAnchorUnixSeconds = org.config.anchor_start_of_month -// ? Math.floor( -// getNextStartOfMonthUnix({ -// interval: itemSets[0].interval, -// intervalCount: itemSets[0].intervalCount, -// }) / 1000, -// ) -// : undefined; - -// if (attachParams.billingAnchor) { -// billingCycleAnchorUnixSeconds = Math.floor( -// attachParams.billingAnchor / 1000, -// ); -// } diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts index cbeef5c9b..376165329 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts @@ -4,10 +4,10 @@ import { isOneOffProduct, type LineItem, } from "@autumn/shared"; -import { applyStripeDiscountsToLineItems } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems"; -import { subToDiscounts } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts"; -import { lineItemsToStripeLines } from "../../../billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines"; import type { AttachContext, StripeSubAction } from "../../typesOld"; +import { applyStripeDiscountsToLineItems } from "../../utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems"; +import { subToDiscounts } from "../../utils/stripeAdapter/applyStripeDiscounts/subToDiscounts"; +import { lineItemsToStripeLines } from "../../utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines"; export const buildStripeInvoiceAction = ({ attachContext, diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts deleted file mode 100644 index a02434d9e..000000000 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - FullCusProduct, - FullCustomer, - OngoingCusProductAction, -} from "@autumn/shared"; -import type Stripe from "stripe"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { buildSubItemUpdate } from "../../../billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate"; -import type { StripeSubAction } from "../../typesOld"; - -export const buildStripeSubAction = ({ - ctx, - stripeSub, - fullCus, - paymentMethod, - ongoingCusProductAction, - newCusProducts, -}: { - ctx: AutumnContext; - stripeSub: Stripe.Subscription; - fullCus: FullCustomer; - paymentMethod?: Stripe.PaymentMethod; - ongoingCusProductAction?: OngoingCusProductAction; - newCusProducts: FullCusProduct[]; -}): StripeSubAction => { - // const { stripeSub } = attachContext; - - const ongoingCusProduct = ongoingCusProductAction?.cusProduct; - - // Build sub item update (what items should be on the sub after this operation) - const subItemUpdate = buildSubItemUpdate({ - ctx, - fullCus, - stripeSub, - paymentMethod, - ongoingCusProduct, - newCusProducts, - }); - - const hasNewItems = subItemUpdate.some((item) => !item.deleted); - const currentSubItems = stripeSub?.items.data ?? []; - - // 1. If subItems update is empty, no update: - if (subItemUpdate.length === 0) { - return { type: "none" }; - } - - // Case 4: Ongoing action is 'cancel' → cancel at period end - if (ongoingCusProductAction?.action === "cancel") { - return { - type: "cancel_at_period_end", - subId: stripeSub!.id, - items: subItemUpdate, - }; - } - - // Case 1: New items but no existing sub → create - if (hasNewItems && !stripeSub) { - return { - type: "create", - items: subItemUpdate.map((item) => ({ - price: item.price, - quantity: item.quantity, - })), - }; - } - - // Case 2: New items and existing sub → update - if (hasNewItems && stripeSub) { - return { - type: "update", - subId: stripeSub.id, - items: subItemUpdate, - }; - } - - // Case 3: No new items but existing sub has items → cancel immediately - if (!hasNewItems && currentSubItems.length > 0 && stripeSub) { - return { - type: "cancel_immediately", - subId: stripeSub.id, - }; - } - - return { type: "none" }; -}; diff --git a/server/src/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan.ts b/server/src/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan.ts new file mode 100644 index 000000000..5b1891f5b --- /dev/null +++ b/server/src/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan.ts @@ -0,0 +1,13 @@ +import type { BillingPlan } from "@/internal/billing/v2/billingPlan"; + +export const addStripeSubscriptionIdToBillingPlan = ({ + billingPlan, + stripeSubscriptionId, +}: { + billingPlan: BillingPlan; + stripeSubscriptionId: string; +}) => { + for (const customerProduct of billingPlan.autumn.insertCustomerProducts) { + customerProduct.subscription_ids = [stripeSubscriptionId]; + } +}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts new file mode 100644 index 000000000..6a2e38c07 --- /dev/null +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -0,0 +1,55 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/billingPlan"; +import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; +import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService"; +import { PriceService } from "@/internal/products/prices/PriceService"; + +export const executeAutumnBillingPlan = async ({ + ctx, + autumnBillingPlan, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const { db } = ctx; + const { + insertCustomerProducts, + updateCustomerProduct, + customPrices, + customEntitlements, + customFreeTrial, + } = autumnBillingPlan; + + await PriceService.insert({ + db, + data: customPrices, + }); + + await EntitlementService.insert({ + db, + data: customEntitlements, + }); + if (customFreeTrial) { + await FreeTrialService.insert({ + db, + data: customFreeTrial, + }); + } + + // 2. Insert new customer products + await insertNewCusProducts({ + ctx, + newCusProducts: insertCustomerProducts, + }); + + // 3. Update customer product + if (updateCustomerProduct) { + await CusProductService.update({ + db, + cusProductId: updateCustomerProduct.customerProduct.id, + updates: updateCustomerProduct.updates, + }); + } +}; diff --git a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts index d8409e7a9..6dfff0535 100644 --- a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts @@ -1,6 +1,6 @@ import { createStripeCli } from "../../../../external/connect/createStripeCli"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { createAndPayInvoice } from "../../billingUtils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice"; +import { createAndPayInvoice } from "../utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice"; import type { AttachContext, StripeCheckoutAction, diff --git a/server/src/internal/billing/v2/fetch/fetchAttachContext.ts b/server/src/internal/billing/v2/fetch/fetchAttachContext.ts index 530a3971a..9a5a59447 100644 --- a/server/src/internal/billing/v2/fetch/fetchAttachContext.ts +++ b/server/src/internal/billing/v2/fetch/fetchAttachContext.ts @@ -3,7 +3,6 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer"; import { getFreeTrialForAttach } from "./fetchAutumnUtils/getFreeTrialForAttach"; import { getProductsForAttach } from "./fetchAutumnUtils/getProductsForAttach"; -import { overrideProduct } from "./fetchAutumnUtils/overrideProduct"; import { resolveAttachActions } from "./fetchAutumnUtils/resolveAttachActions/resolveAttachActions"; import { fetchStripeCustomerForBilling } from "./fetchStripeUtils/fetchStripeCustomerForBilling"; import { fetchStripeSubscriptionForBilling } from "./fetchStripeUtils/fetchStripeSubscriptionForBilling"; @@ -42,16 +41,17 @@ export const fetchAttachContext = async ({ }); // 3. Override product - const { - // customPrices, - // customEnts, - fullProducts: newFullProducts, - } = await overrideProduct({ - ctx, - body, - products: fullProducts, - fullCustomer: fullCus, - }); + // const { + // // customPrices, + // // customEnts, + // fullProducts: newFullProducts, + // } = await overrideProduct({ + // ctx, + // body, + // products: fullProducts, + // fullCustomer: fullCus, + // }); + const newFullProducts = fullProducts; // 4. Get free trial const { @@ -74,12 +74,11 @@ export const fetchAttachContext = async ({ }); // 6. Get stripe customer - const { stripeCus, paymentMethod, now } = await fetchStripeCustomerForBilling( - { + const { stripeCus, paymentMethod, testClockFrozenTime } = + await fetchStripeCustomerForBilling({ ctx, fullCus, - }, - ); + }); const cusProductActions = resolveAttachActions({ fullCus, @@ -94,7 +93,7 @@ export const fetchAttachContext = async ({ stripeSub, stripeCus, paymentMethod, - testClockFrozenTime: now, + testClockFrozenTime, ongoingCusProductAction: cusProductActions.ongoingCusProductAction, scheduledCusProductAction: cusProductActions.scheduledCusProductAction, diff --git a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/getFreeTrialForAttach.ts b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/getFreeTrialForAttach.ts index 4de0502a6..3c4f397d6 100644 --- a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/getFreeTrialForAttach.ts +++ b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/getFreeTrialForAttach.ts @@ -3,14 +3,12 @@ import { type FreeTrial, type FullCustomer, type FullProduct, + initFreeTrial, notNullish, planToDbFreeTrial, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import { - getFreeTrialAfterFingerprint, - validateAndInitFreeTrial, -} from "../../../../products/free-trials/freeTrialUtils"; +import { getFreeTrialAfterFingerprint } from "../../../../products/free-trials/freeTrialUtils"; export const getFreeTrialForAttach = async ({ ctx, @@ -35,8 +33,8 @@ export const getFreeTrialForAttach = async ({ }); const trial = dbFreeTrial - ? validateAndInitFreeTrial({ - freeTrial: dbFreeTrial, + ? initFreeTrial({ + freeTrialParams: dbFreeTrial, internalProductId: products[0].internal_id, isCustom: true, }) diff --git a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts index ef38867c4..b4a4fc461 100644 --- a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts +++ b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/resolveAttachActions/getUncancelAttachActions.ts @@ -55,7 +55,7 @@ export const getUncancelAttachActions = ({ : undefined; return { - ongoingCusProductAction, + ongoingCustomerProduct: ongoingCusProductAction?.cusProduct, scheduledCusProductAction, newProductActions: [], }; diff --git a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts index 9e3993945..27e3b7f01 100644 --- a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts +++ b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts @@ -1,7 +1,6 @@ import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan"; -import { executeSubscriptionUpdate } from "../subscriptionUpdate/execute/executeSubscriptionUpdate"; import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext"; export const handleApiSubscriptionUpdate = createRoute({ @@ -21,12 +20,13 @@ export const handleApiSubscriptionUpdate = createRoute({ params: body, }); - await executeSubscriptionUpdate({ - ctx, - params: body, - updateSubscriptionContext, - subscriptionUpdatePlan, - }); + // Execute... + // await executeApiSubscriptionUpdate({ + // ctx, + // params: body, + // updateSubscriptionContext, + // subscriptionUpdatePlan, + // }); return c.json({ success: true }, 200); }, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts index 41cf48057..78702e74c 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts @@ -1,28 +1,37 @@ import { + CusProductStatus, cusProductToProduct, type SubscriptionUpdateV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import type { BillingPlan } from "@/internal/billing/v2/billingPlan"; +import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan"; import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct"; +import { computeSubscriptionUpdateStripeSubscriptionAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction"; +import { logBillingPlan } from "@/internal/billing/v2/utils/logBillingPlan"; +import { createStripeResourcesForProducts } from "@/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct"; +import { executeStripeSubscriptionAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction"; import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct"; export const computeSubscriptionUpdateCustomPlan = async ({ ctx, - subscriptionUpdateContext, + updateSubscriptionContext, params, }: { ctx: AutumnContext; - subscriptionUpdateContext: UpdateSubscriptionContext; + updateSubscriptionContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; }) => { - // 1. Compute the override plan - const { customerProduct } = subscriptionUpdateContext; + const { customerProduct } = updateSubscriptionContext; const currentFullProduct = cusProductToProduct({ cusProduct: customerProduct, }); + // 1. Compute the custom full product const { fullProduct: customFullProduct, customPrices, @@ -33,21 +42,80 @@ export const computeSubscriptionUpdateCustomPlan = async ({ customItems: params.items, }); - // 2. Compute the new customer product + // 2. Compute the custom trial details + const { freeTrialPlan, customFreeTrial } = + computeSubscriptionUpdateFreeTrialPlan({ + updateSubscriptionContext, + params, + fullProduct: customFullProduct, + }); + + // 3. Compute the new customer product const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({ ctx, - subscriptionUpdateContext, + updateSubscriptionContext, params, fullProduct: customFullProduct, + freeTrialPlan, }); - // // 2. Compute the invoice action - // const invoiceAction = computeSubscriptionUpdateCustomPlanInvoiceAction({ - // ctx, - // updateSubscriptionContext, - // newFullCustomerProduct, - // params, - // }); + // 4. Create stripe prices + const fullCustomer = updateSubscriptionContext.fullCustomer; + await createStripeResourcesForProducts({ + ctx, + fullCustomer, + fullProducts: [customFullProduct], + }); - return newFullCustomerProduct; + // 5. Compute Stripe subscription action + const stripeSubscriptionAction = + computeSubscriptionUpdateStripeSubscriptionAction({ + ctx, + billingContext: updateSubscriptionContext, + newCustomerProduct: newFullCustomerProduct, + freeTrialPlan, + }); + + const billingPlan: BillingPlan = { + stripe: { + subscription: stripeSubscriptionAction, + }, + autumn: { + insertCustomerProducts: [newFullCustomerProduct], + + updateCustomerProduct: { + customerProduct: customerProduct, + updates: { + status: CusProductStatus.Expired, + }, + }, + + customPrices: customPrices, + customEntitlements: customEnts, + customFreeTrial: customFreeTrial, + }, + }; + + logBillingPlan({ ctx, billingPlan }); + + if (stripeSubscriptionAction) { + const updatedStripeSubscription = await executeStripeSubscriptionAction({ + ctx, + subscriptionAction: stripeSubscriptionAction, + }); + + if (updatedStripeSubscription) { + addStripeSubscriptionIdToBillingPlan({ + billingPlan, + stripeSubscriptionId: updatedStripeSubscription.id, + }); + } + } + + await executeAutumnBillingPlan({ + ctx, + autumnBillingPlan: billingPlan.autumn, + }); + + return billingPlan; }; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts deleted file mode 100644 index 3e7405a7f..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - type FullCusProduct, - type SubscriptionUpdateV0Params, - secondsToMs, -} from "@autumn/shared"; -import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import { buildAutumnLineItems } from "../../../compute/computeAutumnUtils/buildAutumnLineItems"; -import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema"; -import { computeSubscriptionUpdateCustomPlanInvoiceRequired } from "./computeSubscriptionUpdateICustomPlanInvoiceRequired"; - -/** - * Computes the invoice action for a custom subscription update. - * - * Determines what invoice operations (create, prorate, void, etc.) are needed - * when a subscription is updated with custom item configurations. - * - * @param ctx - The Autumn request context - * @param updateSubscriptionContext - Context containing customer product and subscription details - * @param params - The subscription update parameters from the API request - * @returns The computed invoice action to be executed - */ -export const computeSubscriptionUpdateCustomPlanInvoiceAction = ({ - ctx, - updateSubscriptionContext, - params, - newFullCustomerProduct, -}: { - ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; - newFullCustomerProduct: FullCusProduct; -}) => { - // 1. Early return and don't create an invoice - - const invoiceRequired = computeSubscriptionUpdateCustomPlanInvoiceRequired({ - ctx, - updateSubscriptionContext, - params, - }); - - if (!invoiceRequired) return undefined; - - const { customerProduct, stripeSubscription, testClockFrozenTime } = - updateSubscriptionContext; - - // 2. Calculate line items - const lineItems = buildAutumnLineItems({ - ctx, - newCusProducts: [newFullCustomerProduct], - ongoingCustomerProduct: customerProduct, - billingCycleAnchor: secondsToMs(stripeSubscription?.billing_cycle_anchor), - testClockFrozenTime, - }); - - // 3. - - console.log("New line items", lineItems); - - return {}; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts new file mode 100644 index 000000000..a0fdfc622 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan.ts @@ -0,0 +1,90 @@ +import type { + FreeTrial, + FullProduct, + SubscriptionUpdateV0Params, +} from "@autumn/shared"; +import { + addDuration, + initFreeTrial, + isProductPaidAndRecurring, + secondsToMs, +} from "@autumn/shared"; +import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; + +interface ComputeSubscriptionUpdateTrialDetailsResult { + freeTrialPlan: { + freeTrial?: FreeTrial | null; + trialEndsAt?: number; + }; + customFreeTrial?: FreeTrial; +} + +export const computeSubscriptionUpdateFreeTrialPlan = ({ + updateSubscriptionContext, + params, + fullProduct, +}: { + updateSubscriptionContext: UpdateSubscriptionContext; + params: SubscriptionUpdateV0Params; + fullProduct: FullProduct; +}): ComputeSubscriptionUpdateTrialDetailsResult => { + const { stripeSubscription, testClockFrozenTime, customerProduct } = + updateSubscriptionContext; + + const now = testClockFrozenTime ?? Date.now(); + + const freeTrialParams = params.free_trial; + + // Case 1: If free trial is null (removing free trial) + if (freeTrialParams === null) { + return { freeTrialPlan: { freeTrial: null } }; + } + + // Case 2: If free trial params are passed in + if (freeTrialParams) { + const dbFreeTrial = initFreeTrial({ + freeTrialParams, + internalProductId: fullProduct.internal_id, + }); + + const trialEndsAt = addDuration({ + now, + durationType: dbFreeTrial.duration, + durationLength: dbFreeTrial.length, + }); + + return { + freeTrialPlan: { freeTrial: dbFreeTrial, trialEndsAt }, + customFreeTrial: dbFreeTrial, + }; + } + + // Case 3: If new product is paid and recurring + if (isProductPaidAndRecurring(fullProduct)) { + if ( + stripeSubscription && + isStripeSubscriptionTrialing(stripeSubscription) + ) { + const trialEndsAt = secondsToMs( + stripeSubscription.trial_end ?? undefined, + ); + + return { + freeTrialPlan: { freeTrial: null, trialEndsAt }, + }; + } else { + return { + freeTrialPlan: { freeTrial: null }, + }; + } + } + + // Case 4: Return free trial / trial ends at from current customer product + return { + freeTrialPlan: { + freeTrial: customerProduct.free_trial, + trialEndsAt: customerProduct.trial_ends_at ?? undefined, + }, + }; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts deleted file mode 100644 index 17e9dff0f..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { SubscriptionUpdateV0Params } from "@autumn/shared"; -import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema"; - -/** - * Determines whether an invoice is required for a custom subscription update. - * - * Evaluates the subscription changes to decide if billing adjustments - * (prorations, charges, credits) necessitate creating an invoice. - * - * @param ctx - The Autumn request context - * @param updateSubscriptionContext - Context containing customer product and subscription details - * @param params - The subscription update parameters from the API request - * @returns `true` if an invoice is required, `false` otherwise - */ -export const computeSubscriptionUpdateCustomPlanInvoiceRequired = ({ - ctx, - updateSubscriptionContext, - params, -}: { - ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; -}) => { - // 1. When to calculate invoice...? - - return false; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts index f7ee79a35..2e02815be 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts @@ -1,28 +1,35 @@ -import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared"; +import { + type FullProduct, + type SubscriptionUpdateV0Params, + secondsToMs, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { cusProductToExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers"; -import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages"; -import { initFullCustomerProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct"; +import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities"; import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers"; +import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages"; +import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; -export const computeSubscriptionUpdateNewCustomerProduct = async ({ +export const computeSubscriptionUpdateNewCustomerProduct = ({ ctx, - subscriptionUpdateContext, params, + updateSubscriptionContext, fullProduct, + freeTrialPlan, }: { ctx: AutumnContext; - subscriptionUpdateContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; + updateSubscriptionContext: UpdateSubscriptionContext; fullProduct: FullProduct; + freeTrialPlan: FreeTrialPlan; }) => { const { customerProduct, fullCustomer, stripeSubscription, stripeSubscriptionSchedule, - } = subscriptionUpdateContext; + } = updateSubscriptionContext; // 1. Get feature quantities const existingUsages = cusProductToExistingUsages({ @@ -41,19 +48,32 @@ export const computeSubscriptionUpdateNewCustomerProduct = async ({ params, }); + // TODO: Move this to a separate function + const billingCycleAnchor = + freeTrialPlan.trialEndsAt ?? + secondsToMs(stripeSubscription?.billing_cycle_anchor); + + const now = updateSubscriptionContext.testClockFrozenTime ?? Date.now(); + // 1. Compute the new full customer product const newFullCustomerProduct = initFullCustomerProduct({ ctx, + initContext: { fullCustomer, fullProduct, featureQuantities, existingUsages, existingRollovers, + resetCycleAnchor: billingCycleAnchor ?? "now", + now, + + freeTrial: freeTrialPlan.freeTrial ?? null, + trialEndsAt: freeTrialPlan.trialEndsAt, }, + initOptions: { isCustom: true, - // resetCycleAnchor, subscriptionId: stripeSubscription?.id, subscriptionScheduleId: stripeSubscriptionSchedule?.id, }, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts deleted file mode 100644 index 73ee82298..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; - -export const computeSubscriptionUpdateResetCycleAnchor = ({ - ctx, - subscriptionUpdateContext, -}: { - ctx: AutumnContext; - subscriptionUpdateContext: UpdateSubscriptionContext; -}) => { - return {}; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction.ts new file mode 100644 index 000000000..124e65e6a --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction.ts @@ -0,0 +1,69 @@ +import type { FullCusProduct } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate"; +import { buildStripeSubscriptionCreateAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionCreateAction"; +import { buildStripeSubscriptionUpdateAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction"; + +export const computeSubscriptionUpdateStripeSubscriptionAction = ({ + ctx, + billingContext, + newCustomerProduct, + freeTrialPlan, +}: { + ctx: AutumnContext; + billingContext: UpdateSubscriptionContext; + newCustomerProduct: FullCusProduct; + freeTrialPlan: FreeTrialPlan; +}) => { + const { customerProduct, stripeSubscription } = billingContext; + + const subItemsUpdate = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [newCustomerProduct], + removeCustomerProducts: [customerProduct], + }); + + // 1. Compute the action type + + // Case 1: No subscription and sub items update is empty -> no action + if (!stripeSubscription && subItemsUpdate.length === 0) { + return undefined; + } + + // Case 2: No subscription and sub items update not empty -> create subscription + if (!stripeSubscription && subItemsUpdate.length > 0) { + return buildStripeSubscriptionCreateAction({ + ctx, + billingContext, + subItemsUpdate, + addInvoiceItems: [], + }); + } + + // Case 3: Cancel subscription + if ( + stripeSubscription && + subItemsUpdate.length === stripeSubscription.items.data.length && + subItemsUpdate.every((item) => item.deleted) + ) { + return { + type: "cancel" as const, + stripeSubscriptionId: stripeSubscription.id, + }; + } + + // Case 4: Update subscription + if (stripeSubscription) { + return buildStripeSubscriptionUpdateAction({ + ctx, + billingContext, + subItemsUpdate, + freeTrialPlan, + }); + } + + return undefined; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts deleted file mode 100644 index 1cc54661e..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; - -export const computeSubscriptionUpdateTrialDetails = ({ - ctx, - subscriptionUpdateContext, -}: { - ctx: AutumnContext; - subscriptionUpdateContext: UpdateSubscriptionContext; -}) => { - return {}; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts index ef4a29565..b58c5637c 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts @@ -4,6 +4,8 @@ import { type SubscriptionUpdateV0Params, } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan"; +import type { BillingPlan } from "../../billingPlan"; import type { SubscriptionUpdatePlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan"; @@ -17,7 +19,7 @@ export type ComputeSubscriptionUpdatePlan = ({ ctx: AutumnContext; updateSubscriptionContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; -}) => SubscriptionUpdatePlan; +}) => SubscriptionUpdatePlan | BillingPlan | Promise; export type ComputeSubscriptionUpdatePlanIntentMap = Partial< Record @@ -30,6 +32,8 @@ const computeSubscriptionUpdatePlanIntentMap: ComputeSubscriptionUpdatePlanInten { [SubscriptionUpdateIntentEnum.UpdateQuantity]: computeSubscriptionUpdateQuantityPlan, + [SubscriptionUpdateIntentEnum.UpdatePlan]: + computeSubscriptionUpdateCustomPlan, }; export const getComputeSubscriptionUpdatePlanFunction = ( diff --git a/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts b/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts similarity index 93% rename from server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts rename to server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts index cb5d5ffba..91c919b84 100644 --- a/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts +++ b/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts @@ -1,4 +1,4 @@ -import type { ExistingRollover, FullCusProduct } from "@shared/index"; +import type { ExistingRollover, FullCusProduct } from "@autumn/shared"; export const applyExistingRollovers = ({ customerProduct, diff --git a/server/src/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers.ts b/server/src/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers.ts similarity index 100% rename from server/src/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers.ts rename to server/src/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers.ts diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts similarity index 92% rename from server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts rename to server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts index 19e77aee1..41e0485fc 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts @@ -4,7 +4,7 @@ import { type ExistingUsages, type FullCusProduct, } from "@autumn/shared"; -import { deductFromCusEntsTypescript } from "../../../balances/track/deductUtils/deductFromCusEntsTypescript"; +import { deductFromCusEntsTypescript } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript"; import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages"; export const applyExistingUsages = ({ diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts similarity index 80% rename from server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts rename to server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts index 1a7153dc1..d8e0c847b 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts @@ -1,12 +1,13 @@ -import { addCusProductToCusEnt, type ExistingUsages } from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { FullCusProduct } from "../../../../../../shared/models/cusProductModels/cusProductModels"; -import { cusEntsToUsage } from "../../../../../../shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage"; import { + addCusProductToCusEnt, + cusEntsToUsage, + type ExistingUsages, + type FullCusProduct, isBooleanCusEnt, isEntityScopedCusEnt, isUnlimitedCusEnt, -} from "../../../../../../shared/utils/cusEntUtils/classifyCusEntUtils"; +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; export const cusProductToExistingUsages = ({ cusProduct, diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts similarity index 100% rename from server/src/internal/billing/billingUtils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts rename to server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement.ts new file mode 100644 index 000000000..8ca244038 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement.ts @@ -0,0 +1,80 @@ +import { + type CustomerEntitlement, + type EntitlementWithFeature, + type InitFullCustomerProductContext, + isBooleanEntitlement, + isUnlimitedEntitlement, +} from "@autumn/shared"; +import { generateId } from "@server/utils/genUtils"; +import { initCustomerEntitlementNextResetAt } from "./initCustomerEntitlementNextResetAt"; +import { initCustomerEntitlementBalance } from "./initCustomerEntitlementBalance"; +import { initCustomerEntitlementUsageAllowed } from "./initCustomerEntitlementUsageAllowed"; + +// MAIN FUNCTION +export const initCustomerEntitlement = ({ + initContext, + entitlement, + cusProductId, +}: { + initContext: InitFullCustomerProductContext; + entitlement: EntitlementWithFeature; + cusProductId: string; +}): CustomerEntitlement => { + const { balance, entities } = initCustomerEntitlementBalance({ + initContext, + entitlement, + }); + + // Get unlimited + const isBoolean = isBooleanEntitlement({ entitlement }); + const unlimited = isBoolean ? null : isUnlimitedEntitlement({ entitlement }); + + // Usage allowed: + const usageAllowed = initCustomerEntitlementUsageAllowed({ + initContext, + entitlement, + }); + + const nextResetAt = initCustomerEntitlementNextResetAt({ + initContext, + entitlement, + }); + + const { fullCustomer } = initContext; + + return { + id: generateId("cus_ent"), + internal_customer_id: fullCustomer.internal_id, + internal_feature_id: entitlement.internal_feature_id, + feature_id: entitlement.feature.id, + customer_id: fullCustomer.id, + entitlement_id: entitlement.id, + customer_product_id: cusProductId, + created_at: Date.now(), + + // Entitlement fields + unlimited, + balance, + additional_balance: 0, + adjustment: 0, + entities, + usage_allowed: usageAllowed, + next_reset_at: nextResetAt, + }; +}; + +// // 3. Define expires at (TODO next time...) +// const isBooleanFeature = entitlement.feature.type === FeatureType.Boolean; +// let usageAllowed = false; +// if ( +// relatedPrice && +// (getBillingType(relatedPrice.config!) === BillingType.UsageInArrear || +// getBillingType(relatedPrice.config!) === BillingType.InArrearProrated) +// ) { +// usageAllowed = true; +// } +// if (notNullish(productOptions?.quantity) && notNullish(newBalance)) { +// newBalance = new Decimal(newBalance!) +// .mul(productOptions?.quantity || 1) +// .toNumber(); +// } diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance.ts new file mode 100644 index 000000000..7a610ff75 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance.ts @@ -0,0 +1,60 @@ +import { + type EntitlementWithFeature, + type EntityBalance, + entToOptions, + entToPrice, + getStartingBalance, + type InitFullCustomerProductContext, + isBooleanEntitlement, + isUnlimitedEntitlement, +} from "@autumn/shared"; +import { initCustomerEntitlementEntities } from "./initCustomerEntitlementEntities"; + +export interface InitCustomerEntitlementBalanceResult { + balance: number; + entities: Record | null; +} + +export const initCustomerEntitlementBalance = ({ + initContext, + entitlement, +}: { + initContext: InitFullCustomerProductContext; + entitlement: EntitlementWithFeature; +}): { balance: number; entities: Record | null } => { + // 1. If entitlement is boolean or unlimited, return 0 + const isBoolean = isBooleanEntitlement({ entitlement }); + const isUnlimited = isUnlimitedEntitlement({ entitlement }); + + if (isBoolean || isUnlimited) { + return { balance: 0, entities: null }; + } + + // 2. Get starting balance + const { fullCustomer, featureQuantities } = initContext; + + const price = entToPrice({ + ent: entitlement, + prices: initContext.fullProduct.prices, + }); + + const options = entToOptions({ + ent: entitlement, + options: featureQuantities, + }); + + const startingBalance = getStartingBalance({ + entitlement, + options, + relatedPrice: price, + }); + + // 3. Get entitlement entities if entity scoped + const entities = initCustomerEntitlementEntities({ + entitlement, + customerEntities: fullCustomer.entities, + startingBalance, + }); + + return { balance: startingBalance, entities }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts new file mode 100644 index 000000000..e218a41c9 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts @@ -0,0 +1,39 @@ +import { + type EntitlementWithFeature, + type Entity, + type EntityBalance, + entitlementFeatureMatchesEntityFeature, + isEntityScopedEntitlement, +} from "@autumn/shared"; + +export const initCustomerEntitlementEntities = ({ + entitlement, + customerEntities, + startingBalance, +}: { + entitlement: EntitlementWithFeature; + customerEntities: Entity[]; + startingBalance: number; +}): Record | null => { + if (!isEntityScopedEntitlement({ entitlement })) return null; + + const entities: Record = {}; + + for (const entity of customerEntities) { + const featureMatches = entitlementFeatureMatchesEntityFeature({ + entitlement, + entity, + }); + + if (!featureMatches) continue; + + entities[entity.id] = { + id: entity.id, + balance: startingBalance, + adjustment: 0, + additional_balance: 0, + }; + } + + return entities; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts new file mode 100644 index 000000000..8b139dfb9 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt.ts @@ -0,0 +1,35 @@ +import { + EntInterval, + type EntitlementWithFeature, + getCycleEnd, + type InitFullCustomerProductContext, + isBooleanEntitlement, + isLifetimeEntitlement, + isUnlimitedEntitlement, +} from "@autumn/shared"; + +export const initCustomerEntitlementNextResetAt = ({ + initContext, + entitlement, +}: { + initContext: InitFullCustomerProductContext; + entitlement: EntitlementWithFeature; +}) => { + // 1. If entitlement is boolean, or unlimited, or lifetime, then next reset at is null + const isLifetime = isLifetimeEntitlement({ entitlement }); + const isUnlimited = isUnlimitedEntitlement({ entitlement }); + const isBoolean = isBooleanEntitlement({ entitlement }); + + if (isLifetime || isUnlimited || isBoolean) return null; + + const { resetCycleAnchor, now } = initContext; + + const nextResetAt = getCycleEnd({ + anchor: resetCycleAnchor, + interval: entitlement.interval ?? EntInterval.Month, + intervalCount: entitlement.interval_count, + now, + }); + + return nextResetAt; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementUsageAllowed.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementUsageAllowed.ts new file mode 100644 index 000000000..efbdde44c --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementUsageAllowed.ts @@ -0,0 +1,23 @@ +import { + type EntitlementWithFeature, + entToPrice, + type InitFullCustomerProductContext, + isPayPerUsePrice, +} from "@autumn/shared"; + +export const initCustomerEntitlementUsageAllowed = ({ + initContext, + entitlement, +}: { + initContext: InitFullCustomerProductContext; + entitlement: EntitlementWithFeature; +}) => { + const price = entToPrice({ + ent: entitlement, + prices: initContext.fullProduct.prices, + }); + + if (!price) return false; + + return isPayPerUsePrice({ price }); +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts new file mode 100644 index 000000000..b060494a1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice.ts @@ -0,0 +1,24 @@ +import { + type CustomerPrice, + type FullCustomer, + generateId, + type Price, +} from "@autumn/shared"; + +export const initCustomerPrice = ({ + price, + fullCus, + cusProductId, +}: { + price: Price; + fullCus: FullCustomer; + cusProductId: string; +}): CustomerPrice => { + return { + id: generateId("cus_price"), + internal_customer_id: fullCus.internal_id, + customer_product_id: cusProductId, + created_at: Date.now(), + price_id: price.id, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts new file mode 100644 index 000000000..9a6d70d2e --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -0,0 +1,99 @@ +import { + CollectionMethod, + type CusProduct, + CusProductStatus, + generateId, + type InitFullCustomerProductContext, + type InitFullCustomerProductOptions, + notNullish, +} from "@autumn/shared"; + +export const initCustomerProduct = ({ + initContext, + initOptions, + customerProductId, +}: { + initContext: InitFullCustomerProductContext; + initOptions?: InitFullCustomerProductOptions; + customerProductId?: string; +}): CusProduct => { + const { + fullCustomer, + fullProduct, + featureQuantities, + freeTrial, + trialEndsAt, + } = initContext; + const { + subscriptionId, + subscriptionScheduleId, + collectionMethod, + isCustom, + apiSemver, + } = initOptions ?? {}; + + const internalEntityId = fullCustomer.entity?.internal_id; + const entityId = fullCustomer.entity?.id; + + const status = initOptions?.status ?? CusProductStatus.Active; + const startsAt = initOptions?.startsAt ?? Date.now(); + + const canceled = notNullish(initOptions?.canceledAt); + const canceledAt = initOptions?.canceledAt; + + const subscriptionIds = subscriptionId ? [subscriptionId] : undefined; + + const scheduleIds = subscriptionScheduleId + ? [subscriptionScheduleId] + : undefined; + + return { + id: customerProductId ?? generateId("cus_prod"), + + internal_customer_id: fullCustomer.internal_id, + customer_id: fullCustomer.id, + internal_entity_id: internalEntityId, + entity_id: entityId, + internal_product_id: fullProduct.internal_id, + product_id: fullProduct.id, + + created_at: Date.now(), + + status, + + // Legacy + // processor: null, + + starts_at: startsAt || Date.now(), + + trial_ends_at: trialEndsAt, + free_trial_id: freeTrial?.id, + + options: featureQuantities, + + canceled, + canceled_at: canceledAt, + + subscription_ids: subscriptionIds, + scheduled_ids: scheduleIds, + collection_method: collectionMethod ?? CollectionMethod.ChargeAutomatically, + + quantity: 1, + + is_custom: isCustom ?? false, + + api_semver: apiSemver ?? null, + }; +}; + +// ? subscriptionStatus +// : isFuture +// ? CusProductStatus.Scheduled +// : CusProductStatus.Active + +// { +// type: ProcessorType.Stripe, +// // subscription_id: subscriptionId, +// // subscription_schedule_id: subscriptionScheduleId, +// // last_invoice_id: lastInvoiceId, +// }, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts new file mode 100644 index 000000000..0e5646113 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts @@ -0,0 +1,82 @@ +import { + type FullCusProduct, + formatMs, + type InitFullCustomerProductContext, + type InitFullCustomerProductOptions, +} from "@autumn/shared"; +import { applyExistingRollovers } from "../handleExistingRollovers/applyExistingRollovers"; +import { generateId } from "@/utils/genUtils"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages"; +import { initCustomerEntitlement } from "./initCustomerEntitlement/initCustomerEntitlement"; +import { initCustomerPrice } from "./initCustomerPrice"; +import { initCustomerProduct } from "./initCustomerProduct"; + +export const initFullCustomerProduct = ({ + // biome-ignore lint/correctness/noUnusedFunctionParameters: will need it at some point + ctx, + initContext, + initOptions, +}: { + ctx: AutumnContext; + initContext: InitFullCustomerProductContext; + initOptions?: InitFullCustomerProductOptions; +}): FullCusProduct => { + const { fullCustomer, fullProduct } = initContext; + + const cusProductId = generateId("cus_prod"); + + console.log( + "Initializing full customer product, next reset at", + formatMs(initContext.resetCycleAnchor), + ); + + const newFullCusEnts = fullProduct.entitlements.map((entitlement) => ({ + ...initCustomerEntitlement({ + initContext, + entitlement, + cusProductId, + }), + entitlement, + replaceables: [], + rollovers: [], + })); + + const newCusPrices = fullProduct.prices.map((price) => ({ + ...initCustomerPrice({ + fullCus: fullCustomer, + price, + cusProductId, + }), + price, + })); + + const newCusProduct = initCustomerProduct({ + initContext, + customerProductId: cusProductId, + }); + + const { entitlements: _ents, prices: _prices, ...rawProduct } = fullProduct; + + const newFullCustomerProduct = { + ...newCusProduct, + product: rawProduct, + customer_entitlements: newFullCusEnts, + customer_prices: newCusPrices, + }; + + // Finally, apply existing usages to new cus product + applyExistingUsages({ + customerProduct: newFullCustomerProduct, + existingUsages: initContext.existingUsages, + entities: fullCustomer.entities, + }); + + // TODO: Add rollovers to customer entitlements + applyExistingRollovers({ + customerProduct: newFullCustomerProduct, + existingRollovers: initContext.existingRollovers ?? [], + }); + + return newFullCustomerProduct; +}; diff --git a/server/src/internal/billing/v2/utils/logBillingPlan.ts b/server/src/internal/billing/v2/utils/logBillingPlan.ts new file mode 100644 index 000000000..99d252b7b --- /dev/null +++ b/server/src/internal/billing/v2/utils/logBillingPlan.ts @@ -0,0 +1,53 @@ +import { formatMs } from "@shared/utils/common/formatUtils/formatUnix"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingPlan } from "@/internal/billing/v2/billingPlan"; + +export const logBillingPlan = ({ + ctx, + billingPlan, +}: { + ctx: AutumnContext; + billingPlan: BillingPlan; +}) => { + ctx.logger.info("Billing plan:", { + autumn: { + insertCustomerProducts: billingPlan.autumn.insertCustomerProducts.map( + (cusProduct) => ({ + id: cusProduct.product.id, + name: cusProduct.product.name, + description: cusProduct.product.description, + entitlements: cusProduct.customer_entitlements.map((ce) => ({ + featureName: ce.entitlement.feature.name, + nextResetAt: formatMs(ce.next_reset_at), + balance: ce.balance, + })), + + trialEndsAt: formatMs(cusProduct.trial_ends_at), + }), + ), + updateCustomerProduct: billingPlan.autumn.updateCustomerProduct + ? { + customerProduct: + billingPlan.autumn.updateCustomerProduct.customerProduct?.product + .id, + updates: billingPlan.autumn.updateCustomerProduct.updates, + } + : undefined, + }, + stripe: { + subscription: billingPlan.stripe.subscription + ? { + type: billingPlan.stripe.subscription.type, + stripeSubscriptionId: + billingPlan.stripe.subscription.type !== "create" + ? billingPlan.stripe.subscription.stripeSubscriptionId + : undefined, + params: + billingPlan.stripe.subscription.type !== "cancel" + ? billingPlan.stripe.subscription.params + : undefined, + } + : undefined, + }, + }); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyAmountOffDiscountToLineItems.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyPercentOffDiscountToLineItems.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts b/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts b/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/discountAppliesToLineItem.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts b/server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/applyStripeDiscounts/subToDiscounts.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct.ts b/server/src/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct.ts new file mode 100644 index 000000000..cada798c1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct.ts @@ -0,0 +1,59 @@ +import type { FullCustomer, FullProduct } from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { checkStripeProductExists } from "@/internal/products/productUtils"; + +export const createStripeResourcesForProducts = async ({ + ctx, + fullProducts, + fullCustomer, +}: { + ctx: AutumnContext; + fullProducts: FullProduct[]; + fullCustomer: FullCustomer; +}) => { + const { db, org, env, logger } = ctx; + + const batchProductUpdates = []; + for (const product of fullProducts) { + batchProductUpdates.push( + checkStripeProductExists({ + db, + org, + env, + product, + logger, + }), + ); + } + await Promise.all(batchProductUpdates); + + const batchPriceUpdates = []; + + const stripeCli = createStripeCli({ + org, + env, + }); + + const internalEntityId = fullCustomer.entity?.internal_id; + + for (const product of fullProducts) { + for (const price of product.prices) { + batchPriceUpdates.push( + createStripePriceIFNotExist({ + db, + stripeCli, + price, + entitlements: product.entitlements, + product, + org, + logger, + internalEntityId, + useCheckout: false, + }), + ); + } + } + await Promise.all(batchPriceUpdates); +}; diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice.ts b/server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts b/server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/payStripeInvoice.ts b/server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/payStripeInvoice.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/payStripeInvoice.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/payStripeInvoice.ts diff --git a/server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/stripeInvoiceOps.ts b/server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/stripeInvoiceOps.ts similarity index 100% rename from server/src/internal/billing/billingUtils/stripeAdapter/stripeInvoiceOps/stripeInvoiceOps.ts rename to server/src/internal/billing/v2/utils/stripeAdapter/stripeInvoiceOps/stripeInvoiceOps.ts diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/stripeItemSpec/findStripeItemSpecUtils.ts b/server/src/internal/billing/v2/utils/stripeAdapter/stripeItemSpec/findStripeItemSpecUtils.ts new file mode 100644 index 000000000..12b40db78 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/stripeItemSpec/findStripeItemSpecUtils.ts @@ -0,0 +1,11 @@ +import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec"; + +export const findStripeItemSpecByStripePriceId = ({ + stripePriceId, + stripeItemSpecs, +}: { + stripePriceId: string; + stripeItemSpecs: StripeItemSpec[]; +}) => { + return stripeItemSpecs.find((item) => item.stripePriceId === stripePriceId); +}; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts new file mode 100644 index 000000000..876024d17 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -0,0 +1,151 @@ +import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec"; +import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; +import { isCustomerProductOnStripeSubscription } from "@shared/utils"; +import type Stripe from "stripe"; +import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils"; +import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { findStripeItemSpecByStripePriceId } from "@/internal/billing/v2/utils/stripeAdapter/stripeItemSpec/findStripeItemSpecUtils"; +import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs"; + +const getFinalCustomerProductsState = ({ + billingContext, + addCustomerProducts = [], + removeCustomerProducts = [], +}: { + billingContext: BillingContext; + addCustomerProducts?: FullCusProduct[]; + removeCustomerProducts?: FullCusProduct[]; +}) => { + const { fullCustomer, stripeSubscription } = billingContext; + + let customerProducts = stripeSubscription + ? fullCustomer.customer_products.filter((cp) => + isCustomerProductOnStripeSubscription({ + customerProduct: cp, + stripeSubscriptionId: stripeSubscription.id, + }), + ) + : []; + + customerProducts = customerProducts.filter( + (cp) => + !removeCustomerProducts.some((cpToRemove) => cpToRemove.id === cp.id), + ); + + customerProducts = [...customerProducts, ...addCustomerProducts]; + + return customerProducts; +}; + +const customerProductsToRecurringStripeItemSpecs = ({ + ctx, + billingContext, + customerProducts, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + customerProducts: FullCusProduct[]; +}) => { + const stripeItemSpecs: StripeItemSpec[] = []; + + for (const customerProduct of customerProducts) { + const { recurringItems } = customerProductToStripeItemSpecs({ + ctx, + billingContext, + customerProduct, + }); + + for (const recurringItem of recurringItems) { + // 1. If price ID is already in the array, update the quantity + const existingItem = stripeItemSpecs.find( + (item) => item.stripePriceId === recurringItem.stripePriceId, + ); + + if (existingItem) { + existingItem.quantity = + (existingItem.quantity ?? 0) + (recurringItem.quantity ?? 0); + } else { + stripeItemSpecs.push(recurringItem); + } + } + } + + return stripeItemSpecs; +}; + +const stripeItemSpecsToSubItemsUpdate = ({ + billingContext, + stripeItemSpecs, +}: { + billingContext: BillingContext; + stripeItemSpecs: StripeItemSpec[]; +}) => { + const { stripeSubscription } = billingContext; + const currentSubscriptionItems = stripeSubscription?.items.data ?? []; + + const subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[] = []; + for (const stripeItemSpec of stripeItemSpecs) { + const existingItem = findStripeSubscriptionItemByStripePriceId({ + stripePriceId: stripeItemSpec.stripePriceId, + stripeSubscriptionItems: currentSubscriptionItems, + }); + + if (existingItem && existingItem.quantity !== stripeItemSpec.quantity) { + subItemsUpdate.push({ + id: existingItem.id, + quantity: stripeItemSpec.quantity, + }); + } else if (!existingItem) { + subItemsUpdate.push({ + price: stripeItemSpec.stripePriceId, + quantity: stripeItemSpec.quantity, + }); + } + } + + for (const subItem of currentSubscriptionItems) { + const stripeItemSpec = findStripeItemSpecByStripePriceId({ + stripePriceId: stripeSubscriptionItemToStripePriceId(subItem), + stripeItemSpecs, + }); + if (!stripeItemSpec) { + subItemsUpdate.push({ id: subItem.id, deleted: true }); + } + } + + return subItemsUpdate; +}; + +export const buildStripeSubscriptionItemsUpdate = ({ + ctx, + billingContext, + addCustomerProducts = [], + removeCustomerProducts = [], +}: { + ctx: AutumnContext; + billingContext: BillingContext; + addCustomerProducts?: FullCusProduct[]; + removeCustomerProducts?: FullCusProduct[]; +}) => { + // 1. Get final customer product state + const customerProducts = getFinalCustomerProductsState({ + billingContext, + addCustomerProducts, + removeCustomerProducts, + }); + + // 2. Get recurring subscription item array (doesn't include one off items) + const recurringItems = customerProductsToRecurringStripeItemSpecs({ + ctx, + billingContext, + customerProducts, + }); + + // 3. Diff it with the current subscription items + return stripeItemSpecsToSubItemsUpdate({ + billingContext, + stripeItemSpecs: recurringItems, + }); +}; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs.ts new file mode 100644 index 000000000..f5c2e9393 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs.ts @@ -0,0 +1,96 @@ +import { + addCusProductToCusEnt, + cusPriceToCusEnt, + cusProductToProduct, + entToOptions, + type FeatureOptions, + type FullCusProduct, + isAllocatedCusEnt, + isOneOffPrice, + notNullish, + type StripeItemSpec, +} from "@autumn/shared"; +import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage"; +import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; + +export const customerProductToStripeItemSpecs = ({ + ctx, + customerProduct, + billingContext, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext?: BillingContext; +}): { + recurringItems: StripeItemSpec[]; + oneOffItems: StripeItemSpec[]; +} => { + const product = cusProductToProduct({ cusProduct: customerProduct }); + + const cusPrices = customerProduct.customer_prices; + const cusEnts = customerProduct.customer_entitlements; + + const fromVercel = billingContext?.paymentMethod?.type === "custom"; + + const { org } = ctx; + + const recurringItems: StripeItemSpec[] = []; + const oneOffItems: StripeItemSpec[] = []; + + for (const cusPrice of cusPrices) { + const price = cusPrice.price; + const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts }); + const ent = cusEnt?.entitlement; + + let options: FeatureOptions | undefined; + let existingUsage: number | undefined; + if (cusEnt) { + const ent = cusEnt.entitlement; + options = entToOptions({ ent, options: customerProduct.options ?? [] }); + + const cusEntWithCusProduct = addCusProductToCusEnt({ + cusEnt, + cusProduct: customerProduct, + }); + + if (isAllocatedCusEnt(cusEntWithCusProduct)) { + existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct }); + } + } + + const stripeItem = priceToStripeItem({ + price, + product, + org, + options, + isCheckout: false, // TODO: Add this back in? + relatedEnt: ent, + existingUsage, + withEntity: notNullish(customerProduct.internal_entity_id), + apiVersion: ctx.apiVersion.value, + fromVercel, + }); + + if (!stripeItem) continue; + + const { lineItem } = stripeItem; + + if (isOneOffPrice(price)) { + oneOffItems.push({ + stripePriceId: lineItem?.price ?? "", + quantity: lineItem?.quantity, + autumnPrice: price, + }); + } else { + recurringItems.push({ + stripePriceId: lineItem?.price ?? "", + quantity: lineItem?.quantity, + autumnPrice: price, + }); + } + } + + return { recurringItems, oneOffItems }; +}; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionCreateAction.ts new file mode 100644 index 000000000..34d1981a2 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -0,0 +1,116 @@ +import { msToSeconds } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; + +export const buildStripeSubscriptionCreateAction = ({ + ctx, + billingContext, + freeTrialPlan, + subItemsUpdate, + addInvoiceItems, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + freeTrialPlan?: FreeTrialPlan; + subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[]; + addInvoiceItems: Stripe.SubscriptionCreateParams.AddInvoiceItem[]; +}) => { + const { stripeCustomer, paymentMethod } = billingContext; + + const trialEndsAt = freeTrialPlan?.trialEndsAt; + const freeTrial = freeTrialPlan?.freeTrial; + + const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required); + const isCustomPaymentMethod = paymentMethod?.type === "custom"; + + // add_invoice_items: invoiceItems, + // collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", + // days_until_due: invoiceOnly ? 30 : undefined, + // billing cycle anchor? + // discounts? + // expand: ["latest_invoice"], + // metadata? + + const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = { + customer: stripeCustomer.id, + items: subItemsUpdate.map((item) => ({ + price: item.price, + quantity: item.quantity, + })), + + billing_mode: { type: "flexible" }, + + payment_behavior: isCustomPaymentMethod + ? "default_incomplete" + : "error_if_incomplete", + + add_invoice_items: addInvoiceItems, + + trial_end: trialEndsAt ? msToSeconds(trialEndsAt) : undefined, + + ...(isFreeTrialWithCardRequired && { + trial_settings: { + end_behavior: { + missing_payment_method: "cancel", + }, + }, + }), + + ...(isCustomPaymentMethod && { + payment_settings: { + save_default_payment_method: "on_subscription", + }, + }), + }; + + return { + type: "create" as const, + params: stripeSubscriptionCreateParams, + }; +}; + +// ...paymentMethodData, +// customer: customer.processor.id, +// items: sanitizeSubItems(subItems), + +// billing_mode: { type: "flexible" }, +// // For custom payment methods (e.g., Vercel), start subscription as incomplete +// // The subscription will become active after external payment is confirmed via Payment Records API +// payment_behavior: isCustomPaymentMethod +// ? "default_incomplete" +// : "error_if_incomplete", +// add_invoice_items: invoiceItems, +// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", +// days_until_due: invoiceOnly ? 30 : undefined, +// billing_cycle_anchor: billingCycleAnchorUnix +// ? Math.floor(billingCycleAnchorUnix / 1000) +// : undefined, + +// discounts, +// expand: ["latest_invoice"], + +// // Pass metadata from attachParams (e.g., Vercel installation/billing plan IDs) +// metadata: metadata || undefined, + +// // For custom payment methods, save the payment method on the subscription +// // so it's available in webhook handlers and for future renewals +// ...(isCustomPaymentMethod && { +// payment_settings: { +// save_default_payment_method: "on_subscription", +// }, +// }), + +// ...{ +// trial_settings: +// freeTrial && !freeTrial.card_required +// ? { +// end_behavior: { +// missing_payment_method: "cancel", +// }, +// } +// : undefined, + +// trial_end: freeTrialToStripeTimestamp({ freeTrial, now }), +// }, diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts new file mode 100644 index 000000000..60343ca3b --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -0,0 +1,52 @@ +import { msToSeconds } from "@shared/utils/common/unixUtils"; +import type Stripe from "stripe"; +import { isStripeSubscriptionCancelling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; + +export const buildStripeSubscriptionUpdateAction = ({ + ctx, + billingContext, + subItemsUpdate, + freeTrialPlan, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[]; + freeTrialPlan?: FreeTrialPlan; +}) => { + const { stripeSubscription } = billingContext; + + if (!stripeSubscription) { + throw new Error( + "[buildStripeSubscriptionUpdateAction] Cannot update subscription: no existing subscription", + ); + } + + const trialEndsAt = freeTrialPlan?.trialEndsAt; + const cancelAtPeriodEnd = isStripeSubscriptionCancelling(stripeSubscription) + ? false + : undefined; + + const params: Stripe.SubscriptionUpdateParams = { + items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined, + trial_end: trialEndsAt ? msToSeconds(trialEndsAt) : undefined, + proration_behavior: "none", + cancel_at_period_end: cancelAtPeriodEnd, + }; + + if ( + params.items === undefined && + params.trial_end === undefined && + params.cancel_at_period_end === undefined + ) { + return undefined; + } + + return { + type: "update" as const, + stripeSubscriptionId: stripeSubscription.id, + params, + }; +}; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/computeStripeSubscriptionActionType.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/computeStripeSubscriptionActionType.ts new file mode 100644 index 000000000..47d3e3bac --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/computeStripeSubscriptionActionType.ts @@ -0,0 +1,40 @@ +// import type Stripe from "stripe"; +// import type { AutumnContext } from "@/honoUtils/HonoEnv"; +// import type { BillingContext } from "@/internal/billing/v2/billingContext"; +// import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan"; + +// export const computeStripeSubscriptionActionType = ({ +// ctx, +// billingContext, +// subItemsUpdate, +// freeTrialPlan, +// }: { +// ctx: AutumnContext; +// billingContext: BillingContext; +// subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[]; +// freeTrialPlan?: FreeTrialPlan; +// }) => { +// const { stripeSubscription } = billingContext; + +// // Case 1: No subscription and sub items update is empty -> no action +// if (!stripeSubscription && subItemsUpdate.length === 0) return undefined; + +// // Case 2: No subscription and sub items update not empty -> create subscription +// if (!stripeSubscription && subItemsUpdate.length > 0) { +// return "create"; +// } + +// // Case 3: Cancel subscription +// if ( +// subItemsUpdate.length === stripeSubscription?.items.data.length && +// subItemsUpdate.every((item) => item.deleted) +// ) { +// return "cancel"; +// } + +// if (stripeSubscription) { +// return "update"; +// } + +// return undefined; +// }; diff --git a/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction.ts new file mode 100644 index 000000000..870b98b05 --- /dev/null +++ b/server/src/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction.ts @@ -0,0 +1,28 @@ +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { StripeSubscriptionAction } from "@/internal/billing/v2/billingPlan"; + +export const executeStripeSubscriptionAction = async ({ + ctx, + subscriptionAction, +}: { + ctx: AutumnContext; + subscriptionAction: StripeSubscriptionAction; +}) => { + const { org, env } = ctx; + const stripeClient = createStripeCli({ org, env }); + + switch (subscriptionAction.type) { + case "update": + return await stripeClient.subscriptions.update( + subscriptionAction.stripeSubscriptionId, + subscriptionAction.params, + ); + case "create": + return await stripeClient.subscriptions.create(subscriptionAction.params); + case "cancel": + await stripeClient.subscriptions.cancel( + subscriptionAction.stripeSubscriptionId, + ); + } +}; diff --git a/server/src/internal/products/free-trials/freeTrialUtils.ts b/server/src/internal/products/free-trials/freeTrialUtils.ts index 64fe71df3..efc29fdeb 100644 --- a/server/src/internal/products/free-trials/freeTrialUtils.ts +++ b/server/src/internal/products/free-trials/freeTrialUtils.ts @@ -1,9 +1,9 @@ import { type CreateFreeTrial, - CreateFreeTrialSchema, ErrCode, type FreeTrial, FreeTrialDuration, + initFreeTrial, type Price, } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; @@ -12,7 +12,6 @@ import { FreeTrialService } from "@server/internal/products/free-trials/FreeTria import { ProductService } from "@server/internal/products/ProductService.js"; import { isOneOff } from "@server/internal/products/productUtils.js"; import RecaseError from "@server/utils/errorUtils.js"; -import { generateId } from "@server/utils/genUtils.js"; import { addDays, addMinutes, addMonths, addYears } from "date-fns"; export const validateOneOffTrial = async ({ @@ -31,28 +30,6 @@ export const validateOneOffTrial = async ({ } }; -export const validateAndInitFreeTrial = ({ - freeTrial, - internalProductId, - isCustom = false, -}: { - freeTrial: CreateFreeTrial; - internalProductId: string; - isCustom?: boolean; -}): FreeTrial => { - const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial); - - return { - ...freeTrialSchema, - id: generateId("ft"), - created_at: Date.now(), - duration: freeTrial.duration || FreeTrialDuration.Day, - internal_product_id: internalProductId, - is_custom: isCustom, - card_required: freeTrial.card_required ?? true, - }; -}; - export const freeTrialsAreSame = ({ ft1, ft2, @@ -176,8 +153,8 @@ export const handleNewFreeTrial = async ({ return curFreeTrial; } - const createdFreeTrial = validateAndInitFreeTrial({ - freeTrial: newFreeTrial, + const createdFreeTrial = initFreeTrial({ + freeTrialParams: newFreeTrial, internalProductId, isCustom, }); diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index ffc8272be..e969e8b4d 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -3,11 +3,9 @@ import { ApiVersion, CouponDurationType, type CreateReward, - ProductItemFeatureType, RewardType, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; -import { createReward } from "@tests/utils/productUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; @@ -34,18 +32,6 @@ const freeProd = constructProduct({ ], }); -const free = constructProduct({ - type: "free", - isDefault: false, - isAddOn: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 12, - }), - ], -}); - const pro = constructProduct({ type: "pro", isDefault: false, @@ -54,28 +40,7 @@ const pro = constructProduct({ featureId: TestFeature.Messages, includedUsage: 12, }), - // constructArrearProratedItem({ - // featureId: TestFeature.Users, - // includedUsage: 1, - // pricePerUnit: 10, - // }), - // constructArrearProratedItem({ - // featureId: TestFeature.Workflows, - // includedUsage: 1, - // pricePerUnit: 25, - // }), - // constructArrearItem({ - // featureId: TestFeature.Words, - // billingUnits: 1, - // price: 0.1, - // }), - // constructPrepaidItem({ - // featureId: TestFeature.Messages, - // billingUnits: 100, - // price: 8, - // }), ], - // intervalCount: 2, }); const premium = constructProduct({ @@ -86,24 +51,7 @@ const premium = constructProduct({ featureId: TestFeature.Messages, includedUsage: 500, }), - // constructArrearItem({ - // featureId: TestFeature.Words, - // billingUnits: 1, - // price: 0.1, - // }), - - // constructArrearProratedItem({ - // featureId: TestFeature.Users, - // includedUsage: 1, - // pricePerUnit: 15, - // }), - // constructPrepaidItem({ - // featureId: TestFeature.Messages, - // billingUnits: 100, - // price: 12, - // }), ], - // intervalCount: 2, }); const freeAddOn = constructRawProduct({ @@ -117,19 +65,6 @@ const freeAddOn = constructRawProduct({ isAddOn: true, }); -const oneOffAddOn = constructRawProduct({ - id: "oneOffAddOn", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - billingUnits: 100, - price: 10, - isOneOff: true, - }), - ], - isAddOn: true, -}); - const oneOffCredits = constructRawProduct({ id: "oneOffCredits", items: [ @@ -157,17 +92,6 @@ const monthlyAddOn = constructRawProduct({ isAddOn: true, }); -const entities = [ - { - id: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "entity2", - feature_id: TestFeature.Users, - }, -]; - // 50% off reward that only applies to pro product const rewardId = "50_percent_off"; const promoCode = "50OFF"; @@ -186,17 +110,6 @@ const reward: CreateReward = { }, }; -const superProd = constructRawProduct({ - id: "super", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - billingUnits: 12, - price: 8, - }), - ], -}); - describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { const customerId = "temp"; const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); @@ -218,66 +131,22 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { freeAddOn, monthlyAddOn, oneOffCredits, - superProd, ], prefix: customerId, }); - await createReward({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - autumn: autumnV1, - reward, - // productId: pro.id, - }); - await autumnV1.attach({ customer_id: customerId, - product_id: superProd.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: 10, - }, - ], + product_id: pro.id, }); - - // await autumnV1.entities.create(customerId, entities); - - // await autumnV1.track({ - // customer_id: customerId, - // feature_id: TestFeature.Workflows, - // value: 4, - // }); - - // await autumnV1.track({ - // customer_id: customerId, - // feature_id: TestFeature.Words, - // value: 1000, - // }); - - // const customer = await CusService.getFull({ - // db: ctx.db, - // idOrInternalId: customerId, - // orgId: ctx.org.id, - // env: ctx.env, - // }); - - // await attachFailedPaymentMethod({ - // stripeCli: ctx.stripeCli, - // customer, - // }); - - // await advanceTestClock({ - // stripeCli: ctx.stripeCli, - // testClockId: result.testClockId, - // advanceTo: toUnix({ - // year: 2025, - // month: 12, - // day: 22, - // }), - // }); }); - return; }); + +// await createReward({ +// db: ctx.db, +// orgId: ctx.org.id, +// env: ctx.env, +// autumn: autumnV1, +// reward, +// // productId: pro.id, +// }); diff --git a/server/tests/alex/init.ts b/server/tests/alex/init.ts index 3d8f73d57..8b11a6e32 100644 --- a/server/tests/alex/init.ts +++ b/server/tests/alex/init.ts @@ -16,7 +16,6 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; import { initEntitlement, initFeature, - initFreeTrial, initPrice, initProduct, } from "../utils/init.js"; @@ -188,6 +187,7 @@ export const alexProducts = { interval: EntInterval.Lifetime, }), }, + freeTrial: null, prices: [ initPrice({ amount: 20.0, // $20.00 @@ -195,10 +195,6 @@ export const alexProducts = { type: "monthly", }), ], - freeTrial: initFreeTrial({ - length: 7, - uniqueFingerprint: true, - }), }), topUpMessages: initProduct({ @@ -305,10 +301,7 @@ export const alexProducts = { amount: 50, }), ], - freeTrial: initFreeTrial({ - length: 7, - uniqueFingerprint: true, - }), + freeTrial: null, }), proTeam: initProduct({ diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts index 4a363f8ef..6f8524a4e 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts @@ -19,7 +19,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => { }); const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], + customerEntitlements: [cusEnt], }); const existingRollovers: ExistingRollover[] = [ @@ -35,7 +35,10 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => { ]; // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); // Assert: rollover should be added to the cusEnt const updatedCusEnt = newCusProduct.customer_entitlements.find( diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts index 7d5b3ec9a..627d24d42 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts @@ -5,37 +5,43 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"), () => { - test("skips rollover when no matching cusEnt exists", () => { - const cusEnt = createMockCusEntitlement({ - internalFeatureId: "internal_feature_a", - featureId: "feature_a", - featureName: "Feature A", - allowance: 100, - balance: 100, +describe( + chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"), + () => { + test("skips rollover when no matching cusEnt exists", () => { + const cusEnt = createMockCusEntitlement({ + internalFeatureId: "internal_feature_a", + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 100, + }); + + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEnt], + }); + + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 500, + usage: 0, + expires_at: null, + entities: {}, + internal_feature_id: "internal_nonexistent_feature", + }, + ]; + + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); + + // Assert: no rollovers added since feature doesn't match + const updatedCusEnt = newCusProduct.customer_entitlements[0]; + expect(updatedCusEnt.rollovers.length).toBe(0); }); - - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], - }); - - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 500, - usage: 0, - expires_at: null, - entities: {}, - internal_feature_id: "internal_nonexistent_feature", - }, - ]; - - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); - - // Assert: no rollovers added since feature doesn't match - const updatedCusEnt = newCusProduct.customer_entitlements[0]; - expect(updatedCusEnt.rollovers.length).toBe(0); - }); -}); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts index 26f9986bd..682b07090 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts @@ -5,50 +5,58 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (multiple rollovers same feature)"), () => { - test("applies multiple rollovers to the same feature", () => { - const internalFeatureId = "internal_words"; +describe( + chalk.yellowBright( + "applyExistingRollovers (multiple rollovers same feature)", + ), + () => { + test("applies multiple rollovers to the same feature", () => { + const internalFeatureId = "internal_words"; - const cusEnt = createMockCusEntitlement({ - internalFeatureId, - featureId: "words", - featureName: "Words", - allowance: 5000, - balance: 5000, + const cusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "words", + featureName: "Words", + allowance: 5000, + balance: 5000, + }); + + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEnt], + }); + + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 1000, + usage: 0, + expires_at: null, + entities: {}, + internal_feature_id: internalFeatureId, + }, + { + id: "rollover_2", + cus_ent_id: "old_cus_ent_id", + balance: 500, + usage: 0, + expires_at: null, + entities: {}, + internal_feature_id: internalFeatureId, + }, + ]; + + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); + + // Assert: both rollovers should be added to the same cusEnt + const updatedCusEnt = newCusProduct.customer_entitlements[0]; + expect(updatedCusEnt.rollovers.length).toBe(2); + expect(updatedCusEnt.rollovers[0].balance).toBe(1000); + expect(updatedCusEnt.rollovers[1].balance).toBe(500); }); - - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], - }); - - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 1000, - usage: 0, - expires_at: null, - entities: {}, - internal_feature_id: internalFeatureId, - }, - { - id: "rollover_2", - cus_ent_id: "old_cus_ent_id", - balance: 500, - usage: 0, - expires_at: null, - entities: {}, - internal_feature_id: internalFeatureId, - }, - ]; - - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); - - // Assert: both rollovers should be added to the same cusEnt - const updatedCusEnt = newCusProduct.customer_entitlements[0]; - expect(updatedCusEnt.rollovers.length).toBe(2); - expect(updatedCusEnt.rollovers[0].balance).toBe(1000); - expect(updatedCusEnt.rollovers[1].balance).toBe(500); - }); -}); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts index c9ef205ed..632c0d5e0 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts @@ -5,53 +5,59 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match)"), () => { - test("applies rollover only to matching feature when multiple cusEnts exist", () => { - const cusEntA = createMockCusEntitlement({ - internalFeatureId: "internal_feature_a", - featureId: "feature_a", - featureName: "Feature A", - allowance: 100, - balance: 100, +describe( + chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match)"), + () => { + test("applies rollover only to matching feature when multiple cusEnts exist", () => { + const cusEntA = createMockCusEntitlement({ + internalFeatureId: "internal_feature_a", + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 100, + }); + + const cusEntB = createMockCusEntitlement({ + internalFeatureId: "internal_feature_b", + featureId: "feature_b", + featureName: "Feature B", + allowance: 200, + balance: 200, + }); + + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEntA, cusEntB], + }); + + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 50, + usage: 0, + expires_at: null, + entities: {}, + internal_feature_id: "internal_feature_a", + }, + ]; + + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); + + // Assert: only feature_a gets the rollover + const updatedCusEntA = newCusProduct.customer_entitlements.find( + (ce) => ce.feature_id === "feature_a", + ); + const updatedCusEntB = newCusProduct.customer_entitlements.find( + (ce) => ce.feature_id === "feature_b", + ); + + expect(updatedCusEntA?.rollovers.length).toBe(1); + expect(updatedCusEntA?.rollovers[0].balance).toBe(50); + expect(updatedCusEntB?.rollovers.length).toBe(0); }); - - const cusEntB = createMockCusEntitlement({ - internalFeatureId: "internal_feature_b", - featureId: "feature_b", - featureName: "Feature B", - allowance: 200, - balance: 200, - }); - - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntA, cusEntB], - }); - - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 50, - usage: 0, - expires_at: null, - entities: {}, - internal_feature_id: "internal_feature_a", - }, - ]; - - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); - - // Assert: only feature_a gets the rollover - const updatedCusEntA = newCusProduct.customer_entitlements.find( - (ce) => ce.feature_id === "feature_a", - ); - const updatedCusEntB = newCusProduct.customer_entitlements.find( - (ce) => ce.feature_id === "feature_b", - ); - - expect(updatedCusEntA?.rollovers.length).toBe(1); - expect(updatedCusEntA?.rollovers[0].balance).toBe(50); - expect(updatedCusEntB?.rollovers.length).toBe(0); - }); -}); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts index 9b70e3fee..ad5023736 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts @@ -5,57 +5,63 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_id)"), () => { - test("applies rollover only to first cusEnt when multiple have same internal_feature_id", () => { - const internalFeatureId = "internal_words"; +describe( + chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_id)"), + () => { + test("applies rollover only to first cusEnt when multiple have same internal_feature_id", () => { + const internalFeatureId = "internal_words"; - const cusEntFirst = createMockCusEntitlement({ - id: "cus_ent_first", - internalFeatureId, - featureId: "words", - featureName: "Words", - allowance: 100, - balance: 100, + const cusEntFirst = createMockCusEntitlement({ + id: "cus_ent_first", + internalFeatureId, + featureId: "words", + featureName: "Words", + allowance: 100, + balance: 100, + }); + + const cusEntSecond = createMockCusEntitlement({ + id: "cus_ent_second", + internalFeatureId, + featureId: "words", + featureName: "Words", + allowance: 200, + balance: 200, + }); + + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEntFirst, cusEntSecond], + }); + + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 50, + usage: 0, + expires_at: null, + entities: {}, + internal_feature_id: internalFeatureId, + }, + ]; + + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); + + // Assert: only the FIRST cusEnt gets the rollover (due to .find() behavior) + const firstCusEnt = newCusProduct.customer_entitlements.find( + (ce) => ce.id === "cus_ent_first", + ); + const secondCusEnt = newCusProduct.customer_entitlements.find( + (ce) => ce.id === "cus_ent_second", + ); + + expect(firstCusEnt?.rollovers.length).toBe(1); + expect(firstCusEnt?.rollovers[0].balance).toBe(50); + expect(secondCusEnt?.rollovers.length).toBe(0); }); - - const cusEntSecond = createMockCusEntitlement({ - id: "cus_ent_second", - internalFeatureId, - featureId: "words", - featureName: "Words", - allowance: 200, - balance: 200, - }); - - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntFirst, cusEntSecond], - }); - - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 50, - usage: 0, - expires_at: null, - entities: {}, - internal_feature_id: internalFeatureId, - }, - ]; - - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); - - // Assert: only the FIRST cusEnt gets the rollover (due to .find() behavior) - const firstCusEnt = newCusProduct.customer_entitlements.find( - (ce) => ce.id === "cus_ent_first", - ); - const secondCusEnt = newCusProduct.customer_entitlements.find( - (ce) => ce.id === "cus_ent_second", - ); - - expect(firstCusEnt?.rollovers.length).toBe(1); - expect(firstCusEnt?.rollovers[0].balance).toBe(50); - expect(secondCusEnt?.rollovers.length).toBe(0); - }); -}); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts index 8e649cff4..48115ee1b 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts @@ -5,45 +5,53 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (zero balance, positive entity balance)"), () => { - test("applies rollover when top-level balance is 0 but entity has positive balance", () => { - const internalFeatureId = "internal_seats"; +describe( + chalk.yellowBright( + "applyExistingRollovers (zero balance, positive entity balance)", + ), + () => { + test("applies rollover when top-level balance is 0 but entity has positive balance", () => { + const internalFeatureId = "internal_seats"; - const cusEnt = createMockCusEntitlement({ - internalFeatureId, - featureId: "seats", - featureName: "Seats", - allowance: 10, - balance: 10, - }); + const cusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "seats", + featureName: "Seats", + allowance: 10, + balance: 10, + }); - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], - }); + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEnt], + }); - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 0, // Top-level balance is 0 - usage: 0, - expires_at: null, - entities: { - entity1: { id: "entity1", balance: 5, usage: 0 }, - entity2: { id: "entity2", balance: 3, usage: 0 }, + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 0, // Top-level balance is 0 + usage: 0, + expires_at: null, + entities: { + entity1: { id: "entity1", balance: 5, usage: 0 }, + entity2: { id: "entity2", balance: 3, usage: 0 }, + }, + internal_feature_id: internalFeatureId, }, - internal_feature_id: internalFeatureId, - }, - ]; + ]; - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); - // Assert: rollover SHOULD be applied because entity has balance > 0 - const updatedCusEnt = newCusProduct.customer_entitlements[0]; - expect(updatedCusEnt.rollovers.length).toBe(1); - expect(updatedCusEnt.rollovers[0].balance).toBe(0); - expect(updatedCusEnt.rollovers[0].entities.entity1.balance).toBe(5); - expect(updatedCusEnt.rollovers[0].entities.entity2.balance).toBe(3); - }); -}); + // Assert: rollover SHOULD be applied because entity has balance > 0 + const updatedCusEnt = newCusProduct.customer_entitlements[0]; + expect(updatedCusEnt.rollovers.length).toBe(1); + expect(updatedCusEnt.rollovers[0].balance).toBe(0); + expect(updatedCusEnt.rollovers[0].entities.entity1.balance).toBe(5); + expect(updatedCusEnt.rollovers[0].entities.entity2.balance).toBe(3); + }); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts index 4d2bee5ed..60dd3198f 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts @@ -5,42 +5,50 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; -describe(chalk.yellowBright("applyExistingRollovers (zero balance, all-zero entity balances)"), () => { - test("skips rollover when balance is 0 and all entity balances are 0", () => { - const internalFeatureId = "internal_seats"; +describe( + chalk.yellowBright( + "applyExistingRollovers (zero balance, all-zero entity balances)", + ), + () => { + test("skips rollover when balance is 0 and all entity balances are 0", () => { + const internalFeatureId = "internal_seats"; - const cusEnt = createMockCusEntitlement({ - internalFeatureId, - featureId: "seats", - featureName: "Seats", - allowance: 10, - balance: 10, - }); + const cusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "seats", + featureName: "Seats", + allowance: 10, + balance: 10, + }); - const newCusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], - }); + const newCusProduct = createMockCustomerProduct({ + customerEntitlements: [cusEnt], + }); - const existingRollovers: ExistingRollover[] = [ - { - id: "rollover_1", - cus_ent_id: "old_cus_ent_id", - balance: 0, - usage: 10, - expires_at: null, - entities: { - entity1: { id: "entity1", balance: 0, usage: 5 }, - entity2: { id: "entity2", balance: 0, usage: 5 }, + const existingRollovers: ExistingRollover[] = [ + { + id: "rollover_1", + cus_ent_id: "old_cus_ent_id", + balance: 0, + usage: 10, + expires_at: null, + entities: { + entity1: { id: "entity1", balance: 0, usage: 5 }, + entity2: { id: "entity2", balance: 0, usage: 5 }, + }, + internal_feature_id: internalFeatureId, }, - internal_feature_id: internalFeatureId, - }, - ]; + ]; - // Act - applyExistingRollovers({ newCusProduct, existingRollovers }); + // Act + applyExistingRollovers({ + customerProduct: newCusProduct, + existingRollovers, + }); - // Assert: rollover should NOT be applied (balance 0, all entity balances 0) - const updatedCusEnt = newCusProduct.customer_entitlements[0]; - expect(updatedCusEnt.rollovers.length).toBe(0); - }); -}); + // Assert: rollover should NOT be applied (balance 0, all entity balances 0) + const updatedCusEnt = newCusProduct.customer_entitlements[0]; + expect(updatedCusEnt.rollovers.length).toBe(0); + }); + }, +); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts index 29b794a29..fceb0d87b 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts @@ -20,7 +20,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => { }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEnt], + customerEntitlements: [cusEnt], }); const existingUsages: ExistingUsages = { @@ -28,7 +28,11 @@ describe(chalk.yellowBright("applyExistingUsages"), () => { }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ + customerProduct: cusProduct, + existingUsages, + entities: [], + }); // Assert: balance should be 5000 - 1500 = 3500 const updatedCusEnt = cusProduct.customer_entitlements.find( diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts index 467ea38d9..19098a0bd 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts @@ -31,7 +31,7 @@ describe( }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntA, cusEntB], + customerEntitlements: [cusEntA, cusEntB], }); // 2 entities on feature A, 3 entities on feature B @@ -66,7 +66,11 @@ describe( const existingUsages: ExistingUsages = {}; // Act - applyExistingUsages({ cusProduct, existingUsages, entities }); + applyExistingUsages({ + customerProduct: cusProduct, + existingUsages, + entities, + }); // Assert: Feature A balance = 10 - 2 = 8, Feature B balance = 10 - 3 = 7 const updatedCusEntA = cusProduct.customer_entitlements.find( @@ -90,8 +94,8 @@ describe( balance: 10, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntA], + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [cusEntA], }); // 2 entities on feature A @@ -114,10 +118,10 @@ describe( }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities }); + applyExistingUsages({ customerProduct, existingUsages, entities }); // Assert: Entity count (2) takes priority, balance = 10 - 2 = 8 - const updatedCusEntA = cusProduct.customer_entitlements.find( + const updatedCusEntA = customerProduct.customer_entitlements.find( (ce) => ce.feature_id === "feature_a", ); expect(updatedCusEntA?.balance).toBe(8); @@ -143,8 +147,8 @@ describe( balance: 2, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntA1, cusEntA2], + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [cusEntA1, cusEntA2], }); // 3 entities on feature A @@ -169,10 +173,10 @@ describe( const existingUsages: ExistingUsages = {}; // Act - applyExistingUsages({ cusProduct, existingUsages, entities }); + applyExistingUsages({ customerProduct, existingUsages, entities }); // Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1 - const updatedCusEnts = cusProduct.customer_entitlements.filter( + const updatedCusEnts = customerProduct.customer_entitlements.filter( (ce) => ce.feature_id === "feature_a", ); expect(updatedCusEnts[0]?.balance).toBe(0); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts index a18e85478..01302ce0b 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts @@ -34,7 +34,7 @@ describe( }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array + customerEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array }); // Apply 7 usage (should deplete lifetime first, then take 2 from monthly) @@ -43,7 +43,11 @@ describe( }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ + customerProduct: cusProduct, + existingUsages, + entities: [], + }); // Assert: Lifetime should be depleted first (0), then monthly should have 3 remaining const updatedLifetime = cusProduct.customer_entitlements.find( @@ -82,8 +86,8 @@ describe( usageAllowed: true, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array }); // Apply 7 usage (should deplete prepaid first, then take 2 from pay-per-use) @@ -92,13 +96,13 @@ describe( }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ customerProduct, existingUsages, entities: [] }); // Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining - const updatedPrepaid = cusProduct.customer_entitlements.find( + const updatedPrepaid = customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === false, ); - const updatedPayPerUse = cusProduct.customer_entitlements.find( + const updatedPayPerUse = customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === true, ); @@ -135,8 +139,8 @@ describe( nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order }); // Apply 12 usage (5 from prepaid, 7 from pay-per-use -> goes to -2) @@ -145,12 +149,12 @@ describe( }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ customerProduct, existingUsages, entities: [] }); - const updatedPrepaid = cusProduct.customer_entitlements.find( + const updatedPrepaid = customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === false, ); - const updatedPayPerUse = cusProduct.customer_entitlements.find( + const updatedPayPerUse = customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === true, ); @@ -199,8 +203,12 @@ describe( }); // Add in random order - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [payPerUseMonthly, prepaidLifetime, prepaidMonthly], + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [ + payPerUseMonthly, + prepaidLifetime, + prepaidMonthly, + ], }); // Apply 5 usage (should take 2 from prepaid monthly, 2 from prepaid lifetime, 1 from pay-per-use monthly) @@ -209,11 +217,11 @@ describe( }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ customerProduct, existingUsages, entities: [] }); // Find each cusEnt by their unique characteristics const findCusEnt = (usageAllowed: boolean, interval: EntInterval) => - cusProduct.customer_entitlements.find( + customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === usageAllowed && ce.entitlement.interval === interval, diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts index aa1ef57ae..e3a719622 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts @@ -26,7 +26,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [entityScopedCusEnt], + customerEntitlements: [entityScopedCusEnt], }); // Apply entity usages: entity1: 50, entity2: 100, entity3: 25 @@ -42,7 +42,11 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ + customerProduct: cusProduct, + existingUsages, + entities: [], + }); // Assert const updatedCusEnt = cusProduct.customer_entitlements[0]; @@ -68,7 +72,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [nonEntityScopedCusEnt], + customerEntitlements: [nonEntityScopedCusEnt], }); // Try to apply entity usages to non-entity-scoped cusEnt @@ -83,7 +87,11 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ + customerProduct: cusProduct, + existingUsages, + entities: [], + }); // Assert: Balance should remain unchanged since cusEnt is not entity-scoped const updatedCusEnt = cusProduct.customer_entitlements[0]; @@ -111,8 +119,8 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [entityScopedCusEnt], + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [entityScopedCusEnt], }); // Apply top-level usage (no targetEntityId) - should aggregate across entities @@ -126,10 +134,10 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ customerProduct, existingUsages, entities: [] }); // Assert: Deduction should flow through entities - const updatedCusEnt = cusProduct.customer_entitlements[0]; + const updatedCusEnt = customerProduct.customer_entitlements[0]; expect(updatedCusEnt.entities).not.toBeNull(); // The total deducted should be 80, distributed across entities @@ -161,8 +169,8 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }, }); - const cusProduct = createMockCustomerProduct({ - cusEntitlements: [entityScopedCusEnt], + const customerProduct = createMockCustomerProduct({ + customerEntitlements: [entityScopedCusEnt], }); // Apply entity usages that exceed balances @@ -177,10 +185,10 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + applyExistingUsages({ customerProduct, existingUsages, entities: [] }); // Assert: Entity balances should go negative - const updatedCusEnt = cusProduct.customer_entitlements[0]; + const updatedCusEnt = customerProduct.customer_entitlements[0]; expect(updatedCusEnt.entities).not.toBeNull(); expect(updatedCusEnt.entities?.entity1.balance).toBe(-20); // 50 - 70 = -20 expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20 diff --git a/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts index db5eafae4..45408e169 100644 --- a/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts @@ -33,7 +33,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [lifetimeCusEnt, monthlyCusEnt], + customerEntitlements: [lifetimeCusEnt, monthlyCusEnt], }); // Act @@ -74,7 +74,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }); const cusProduct = createMockCustomerProduct({ - cusEntitlements: [topLevelCusEnt, entityScopedCusEnt], + customerEntitlements: [topLevelCusEnt, entityScopedCusEnt], }); // Act @@ -126,7 +126,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { ]; const cusProduct = createMockCustomerProduct({ - cusEntitlements: [cusEntWithRollover], + customerEntitlements: [cusEntWithRollover], }); // Act diff --git a/server/tests/unit-tests/billing/insert-full-cus-product/initCusEntitlementBalance.test.ts b/server/tests/unit-tests/billing/insert-full-cus-product/initCusEntitlementBalance.test.ts deleted file mode 100644 index e7c9e0020..000000000 --- a/server/tests/unit-tests/billing/insert-full-cus-product/initCusEntitlementBalance.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - AllowanceType, - type AttachReplaceable, - type EntitlementWithFeature, - type Feature, - type FeatureOptions, - FeatureType, - type InitFullCusProductContext, -} from "@autumn/shared"; -import ctx from "@tests/utils/testInitUtils/createTestContext"; -import chalk from "chalk"; -import { initCusEntitlementBalance } from "@/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance"; - -const createMockEntitlement = ({ - feature, - featureType, - allowanceType, -}: { - feature: Feature; - featureType: FeatureType; - allowanceType: AllowanceType; -}): EntitlementWithFeature => ({ - id: "ent_test", - created_at: Date.now(), - internal_feature_id: "feat_internal", - internal_product_id: "prod_internal", - is_custom: false, - allowance_type: allowanceType, - allowance: 100, - interval: null, - interval_count: 1, - carry_from_previous: false, - entity_feature_id: null, - feature_id: "feat_test", - usage_limit: null, - rollover: null, - feature, -}); - -const createMockInsertContext = (): InitFullCusProductContext => ({ - fullCus: {} as InitFullCusProductContext["fullCus"], - product: {} as InitFullCusProductContext["product"], - featureQuantities: [] as FeatureOptions[], - replaceables: [] as AttachReplaceable[], -}); - -describe(chalk.yellowBright("initCusEntitlementBalance"), () => { - test("returns { newBalance: 0, newEntities: null } for boolean entitlements", () => { - const booleanFeature = ctx.features.find( - (f) => f.type === FeatureType.Boolean, - )!; - - const entitlement = createMockEntitlement({ - feature: booleanFeature, - featureType: FeatureType.Boolean, - allowanceType: AllowanceType.Fixed, - }); - - const result = initCusEntitlementBalance({ - insertContext: createMockInsertContext(), - entitlement, - }); - - expect(result).toEqual({ balance: 0, entities: null }); - }); -}); diff --git a/server/tests/unit-tests/billing/stripe/build-subscription-items-update/billingContextMocks.ts b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/billingContextMocks.ts new file mode 100644 index 000000000..5f62648e1 --- /dev/null +++ b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/billingContextMocks.ts @@ -0,0 +1,19 @@ +import type { FullCusProduct } from "@autumn/shared"; +import { + createMockFullCustomer, + createMockStripeCustomer, +} from "@tests/utils/mockUtils/customerMocks"; +import type Stripe from "stripe"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; + +export const createMockBillingContext = ({ + customerProducts = [], + stripeSubscription, +}: { + customerProducts?: FullCusProduct[]; + stripeSubscription?: Stripe.Subscription; +}): BillingContext => ({ + fullCustomer: createMockFullCustomer({ customerProducts }), + stripeCustomer: createMockStripeCustomer(), + stripeSubscription, +}); diff --git a/server/tests/unit-tests/billing/stripe/build-subscription-items-update/build-subscription-items-update.test.ts b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/build-subscription-items-update.test.ts new file mode 100644 index 000000000..1518e8a9c --- /dev/null +++ b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/build-subscription-items-update.test.ts @@ -0,0 +1,676 @@ +import { describe, expect, test } from "bun:test"; +import { createMockCtx } from "@tests/utils/mockUtils/contextMocks"; +import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockFeature } from "@tests/utils/mockUtils/featureMocks"; +import { + createMockAllocatedPrice, + createMockConsumablePrice, + createMockCustomerPrice, + createMockFixedPrice, + createMockOneOffPrice, + createMockPrepaidPrice, +} from "@tests/utils/mockUtils/priceMocks"; +import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks"; +import chalk from "chalk"; +import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate"; +import { createMockBillingContext } from "./billingContextMocks"; +import { createMockStripeSubscription } from "./stripeSubscriptionMocks"; + +// ============ TESTS ============ + +describe(chalk.yellowBright("buildStripeSubscriptionItemsUpdate"), () => { + describe("no existing subscription", () => { + test("1. new customer product with fixed price", () => { + const fixedPrice = createMockFixedPrice({ + id: "price_fixed", + stripePriceId: "stripe_price_fixed", + }); + + const product = createMockFullProduct({ + id: "prod_pro", + prices: [fixedPrice], + stripeProductId: "stripe_prod_pro", + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_pro", + product, + customerPrices: [createMockCustomerPrice({ price: fixedPrice })], + }); + + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_price_fixed", + quantity: 1, + }); + }); + + test("2. new customer product with prepaid price", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const prepaidPrice = createMockPrepaidPrice({ + id: "price_prepaid", + featureId: "credits", + stripePriceId: "stripe_price_prepaid", + }); + + const product = createMockFullProduct({ + id: "prod_pro", + prices: [prepaidPrice], + stripeProductId: "stripe_prod_pro", + }); + + const cusEnt = createMockCusEntitlement({ + featureId: "credits", + featureName: "Credits", + allowance: 100, + balance: 100, + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_pro", + product, + customerPrices: [createMockCustomerPrice({ price: prepaidPrice })], + customerEntitlements: [cusEnt], + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 5, + }, + ], + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_price_prepaid", + quantity: 5, + }); + }); + + test("3. consumable price with entity uses stripe_empty_price_id", () => { + const feature = createMockFeature({ + id: "api_calls", + name: "API Calls", + }); + + const consumablePrice = createMockConsumablePrice({ + id: "price_usage", + featureId: "api_calls", + stripePriceId: "stripe_price_usage", + stripeEmptyPriceId: "stripe_empty_price_usage", + }); + + const product = createMockFullProduct({ + id: "prod_usage", + prices: [consumablePrice], + stripeProductId: "stripe_prod_usage", + }); + + const cusEnt = createMockCusEntitlement({ + featureId: "api_calls", + featureName: "API Calls", + allowance: 1000, + balance: 1000, + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_usage", + product, + customerPrices: [createMockCustomerPrice({ price: consumablePrice })], + customerEntitlements: [cusEnt], + internalEntityId: "entity_123", // Has entity + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_empty_price_usage", + quantity: 0, + }); + }); + }); + + describe("existing subscription", () => { + test("4. remove one customer product, add another", () => { + const oldPrice = createMockFixedPrice({ + id: "price_old", + stripePriceId: "stripe_price_old", + }); + + const newPrice = createMockFixedPrice({ + id: "price_new", + stripePriceId: "stripe_price_new", + }); + + const oldProduct = createMockFullProduct({ + id: "prod_old", + prices: [oldPrice], + stripeProductId: "stripe_prod_old", + }); + + const newProduct = createMockFullProduct({ + id: "prod_new", + prices: [newPrice], + stripeProductId: "stripe_prod_new", + }); + + const oldCustomerProduct = createMockCustomerProduct({ + id: "cus_prod_old", + productId: "prod_old", + product: oldProduct, + customerPrices: [createMockCustomerPrice({ price: oldPrice })], + subscriptionIds: ["sub_123"], + }); + + const newCustomerProduct = createMockCustomerProduct({ + id: "cus_prod_new", + productId: "prod_new", + product: newProduct, + customerPrices: [createMockCustomerPrice({ price: newPrice })], + }); + + const stripeSubscription = createMockStripeSubscription({ + id: "sub_123", + items: [{ id: "si_old", priceId: "stripe_price_old", quantity: 1 }], + }); + + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [oldCustomerProduct], + stripeSubscription, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [newCustomerProduct], + removeCustomerProducts: [oldCustomerProduct], + }); + + expect(result).toHaveLength(2); + + // Should delete old item + const deletedItem = result.find((item) => item.deleted === true); + expect(deletedItem).toBeDefined(); + expect(deletedItem?.id).toBe("si_old"); + + // Should add new item + const newItem = result.find((item) => item.price === "stripe_price_new"); + expect(newItem).toBeDefined(); + expect(newItem?.quantity).toBe(1); + }); + + test("5. update quantity of existing subscription item", () => { + const feature = createMockFeature({ + id: "seats", + name: "Seats", + }); + + const prepaidPrice = createMockPrepaidPrice({ + id: "price_seats", + featureId: "seats", + stripePriceId: "stripe_price_seats", + }); + + const product = createMockFullProduct({ + id: "prod_team", + prices: [prepaidPrice], + stripeProductId: "stripe_prod_team", + }); + + const cusEnt = createMockCusEntitlement({ + featureId: "seats", + featureName: "Seats", + allowance: 10, + balance: 10, + }); + + // Current: 5 seats, updating to 10 seats + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_team", + product, + customerPrices: [createMockCustomerPrice({ price: prepaidPrice })], + customerEntitlements: [cusEnt], + subscriptionIds: ["sub_123"], + options: [ + { + feature_id: "seats", + internal_feature_id: "internal_seats", + quantity: 10, // Updated quantity + }, + ], + }); + + const stripeSubscription = createMockStripeSubscription({ + id: "sub_123", + items: [ + { id: "si_seats", priceId: "stripe_price_seats", quantity: 5 }, // Old quantity + ], + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [customerProduct], + stripeSubscription, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "si_seats", + quantity: 10, + }); + }); + }); + + describe("edge cases", () => { + test("6. no changes needed returns empty array", () => { + const fixedPrice = createMockFixedPrice({ + id: "price_fixed", + stripePriceId: "stripe_price_fixed", + }); + + const product = createMockFullProduct({ + id: "prod_pro", + prices: [fixedPrice], + stripeProductId: "stripe_prod_pro", + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_pro", + product, + customerPrices: [createMockCustomerPrice({ price: fixedPrice })], + subscriptionIds: ["sub_123"], + }); + + const stripeSubscription = createMockStripeSubscription({ + id: "sub_123", + items: [{ id: "si_fixed", priceId: "stripe_price_fixed", quantity: 1 }], + }); + + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [customerProduct], + stripeSubscription, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + }); + + expect(result).toHaveLength(0); + }); + + test("7. multiple products with same price ID aggregates quantities", () => { + const feature = createMockFeature({ + id: "seats", + name: "Seats", + }); + + const sharedPrice = createMockPrepaidPrice({ + id: "price_seats", + featureId: "seats", + stripePriceId: "stripe_price_seats", + }); + + const product1 = createMockFullProduct({ + id: "prod_team_1", + prices: [sharedPrice], + stripeProductId: "stripe_prod_team_1", + }); + + const product2 = createMockFullProduct({ + id: "prod_team_2", + prices: [sharedPrice], + stripeProductId: "stripe_prod_team_2", + }); + + const cusEnt1 = createMockCusEntitlement({ + id: "cus_ent_1", + featureId: "seats", + featureName: "Seats", + allowance: 5, + balance: 5, + }); + + const cusEnt2 = createMockCusEntitlement({ + id: "cus_ent_2", + featureId: "seats", + featureName: "Seats", + allowance: 3, + balance: 3, + }); + + const customerProduct1 = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_team_1", + product: product1, + customerPrices: [createMockCustomerPrice({ price: sharedPrice })], + customerEntitlements: [cusEnt1], + options: [ + { + feature_id: "seats", + internal_feature_id: "internal_seats", + quantity: 5, + }, + ], + }); + + const customerProduct2 = createMockCustomerProduct({ + id: "cus_prod_2", + productId: "prod_team_2", + product: product2, + customerPrices: [createMockCustomerPrice({ price: sharedPrice })], + customerEntitlements: [cusEnt2], + options: [ + { + feature_id: "seats", + internal_feature_id: "internal_seats", + quantity: 3, + }, + ], + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct1, customerProduct2], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_price_seats", + quantity: 8, // 5 + 3 + }); + }); + + test("8. empty customer products marks all current items as deleted", () => { + const stripeSubscription = createMockStripeSubscription({ + id: "sub_123", + items: [ + { id: "si_1", priceId: "stripe_price_1", quantity: 1 }, + { id: "si_2", priceId: "stripe_price_2", quantity: 2 }, + ], + }); + + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + }); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ id: "si_1", deleted: true }); + expect(result).toContainEqual({ id: "si_2", deleted: true }); + }); + + test("9. no stripe subscription and no products returns empty array", () => { + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + }); + + expect(result).toHaveLength(0); + }); + + test("10. allocated price uses stripe_empty_price_id with quantity 0", () => { + const feature = createMockFeature({ + id: "storage", + name: "Storage", + }); + + const allocatedPrice = createMockAllocatedPrice({ + id: "price_storage", + featureId: "storage", + stripePriceId: "stripe_price_storage", + stripeEmptyPriceId: "stripe_empty_price_storage", + }); + + const product = createMockFullProduct({ + id: "prod_storage", + prices: [allocatedPrice], + stripeProductId: "stripe_prod_storage", + }); + + const cusEnt = createMockCusEntitlement({ + featureId: "storage", + featureName: "Storage", + allowance: 100, + balance: 100, + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_storage", + product, + customerPrices: [createMockCustomerPrice({ price: allocatedPrice })], + customerEntitlements: [cusEnt], + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_empty_price_storage", + quantity: 0, + }); + }); + + test("11. one-off prices are NOT included in subscription items", () => { + const fixedPrice = createMockFixedPrice({ + id: "price_fixed", + stripePriceId: "stripe_price_fixed", + }); + + const oneOffPrice = createMockOneOffPrice({ + id: "price_oneoff", + stripePriceId: "stripe_price_oneoff", + }); + + const product = createMockFullProduct({ + id: "prod_pro", + prices: [fixedPrice, oneOffPrice], + stripeProductId: "stripe_prod_pro", + }); + + const customerProduct = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_pro", + product, + customerPrices: [ + createMockCustomerPrice({ price: fixedPrice }), + createMockCustomerPrice({ price: oneOffPrice }), + ], + }); + + const ctx = createMockCtx({ features: [] }); + const billingContext = createMockBillingContext({ + customerProducts: [], + stripeSubscription: undefined, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct], + }); + + // Should only include the fixed price, not the one-off + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + price: "stripe_price_fixed", + quantity: 1, + }); + }); + + test("12. adding second entity customer product with consumable price keeps quantity at 0", () => { + const feature = createMockFeature({ + id: "api_calls", + name: "API Calls", + }); + + const consumablePrice = createMockConsumablePrice({ + id: "price_usage", + featureId: "api_calls", + stripePriceId: "stripe_price_usage", + stripeEmptyPriceId: "stripe_empty_price_usage", + }); + + const product = createMockFullProduct({ + id: "prod_usage", + prices: [consumablePrice], + stripeProductId: "stripe_prod_usage", + }); + + const cusEnt1 = createMockCusEntitlement({ + id: "cus_ent_1", + featureId: "api_calls", + featureName: "API Calls", + allowance: 1000, + balance: 1000, + }); + + const cusEnt2 = createMockCusEntitlement({ + id: "cus_ent_2", + featureId: "api_calls", + featureName: "API Calls", + allowance: 1000, + balance: 1000, + }); + + // First entity customer product (already exists on subscription) + const customerProduct1 = createMockCustomerProduct({ + id: "cus_prod_1", + productId: "prod_usage", + product, + customerPrices: [createMockCustomerPrice({ price: consumablePrice })], + customerEntitlements: [cusEnt1], + internalEntityId: "entity_1", + subscriptionIds: ["sub_123"], + }); + + // Second entity customer product (being added) + const customerProduct2 = createMockCustomerProduct({ + id: "cus_prod_2", + productId: "prod_usage", + product, + customerPrices: [createMockCustomerPrice({ price: consumablePrice })], + customerEntitlements: [cusEnt2], + internalEntityId: "entity_2", + }); + + const stripeSubscription = createMockStripeSubscription({ + id: "sub_123", + items: [ + { + id: "si_usage", + priceId: "stripe_empty_price_usage", + quantity: 0, + }, + ], + }); + + const ctx = createMockCtx({ features: [feature] }); + const billingContext = createMockBillingContext({ + customerProducts: [customerProduct1], + stripeSubscription, + }); + + const result = buildStripeSubscriptionItemsUpdate({ + ctx, + billingContext, + addCustomerProducts: [customerProduct2], + }); + + // Quantity should still be 0 (consumable prices aggregate to 0) + // Either no update needed (empty array) or quantity stays at 0 + const usageItem = result.find( + (item) => + item.price === "stripe_empty_price_usage" || item.id === "si_usage", + ); + + // If there's an update, quantity should be 0 + if (usageItem && "quantity" in usageItem) { + expect(usageItem.quantity).toBe(0); + } + // Otherwise no update needed is also valid (means quantity unchanged at 0) + }); + }); +}); diff --git a/server/tests/unit-tests/billing/stripe/build-subscription-items-update/stripeSubscriptionMocks.ts b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/stripeSubscriptionMocks.ts new file mode 100644 index 000000000..ae30ba0b0 --- /dev/null +++ b/server/tests/unit-tests/billing/stripe/build-subscription-items-update/stripeSubscriptionMocks.ts @@ -0,0 +1,52 @@ +import type Stripe from "stripe"; + +export const createMockStripeSubscriptionItem = ({ + id, + priceId, + quantity, +}: { + id: string; + priceId: string; + quantity: number; +}): Stripe.SubscriptionItem => + ({ + id, + object: "subscription_item", + created: Math.floor(Date.now() / 1000), + quantity, + price: { + id: priceId, + object: "price", + active: true, + currency: "usd", + type: "recurring", + } as Stripe.Price, + }) as Stripe.SubscriptionItem; + +export const createMockStripeSubscription = ({ + id, + items = [], +}: { + id: string; + items?: { id: string; priceId: string; quantity: number }[]; +}): Stripe.Subscription => { + const subscriptionItems = items.map((item) => + createMockStripeSubscriptionItem({ + id: item.id, + priceId: item.priceId, + quantity: item.quantity, + }), + ); + + return { + id, + object: "subscription", + status: "active", + items: { + object: "list", + data: subscriptionItems, + has_more: false, + url: `/v1/subscription_items?subscription=${id}`, + }, + } as Stripe.Subscription; +}; diff --git a/server/tests/utils/init.ts b/server/tests/utils/init.ts index 1cd0b2069..e66c7b3cc 100644 --- a/server/tests/utils/init.ts +++ b/server/tests/utils/init.ts @@ -11,7 +11,6 @@ import { type Feature, FeatureType, FeatureUsageType, - FreeTrialDuration, type Organization, PriceType, type ProductItem, @@ -223,23 +222,6 @@ export const initPrice = ({ } }; -export const initFreeTrial = ({ - length, - uniqueFingerprint = false, - cardRequired = true, -}: { - length: number; - uniqueFingerprint?: boolean; - cardRequired?: boolean; -}): CreateFreeTrial => { - return { - length, - unique_fingerprint: uniqueFingerprint, - duration: FreeTrialDuration.Day, - card_required: cardRequired, - }; -}; - export const initProduct = ({ id, isDefault = false, diff --git a/server/tests/utils/mockUtils/contextMocks.ts b/server/tests/utils/mockUtils/contextMocks.ts index 9395f1459..4bffbb5c2 100644 --- a/server/tests/utils/mockUtils/contextMocks.ts +++ b/server/tests/utils/mockUtils/contextMocks.ts @@ -1,12 +1,29 @@ -import type { Feature } from "@autumn/shared"; +import { + ApiVersion, + ApiVersionClass, + AppEnv, + type Feature, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +export const createMockOrg = () => ({ + id: "org_test", + name: "Test Organization", + slug: "test-org", + default_currency: "usd", + stripe_account_id: "acct_test", +}); + export const createMockCtx = ({ - features, + features = [], + org, }: { - features: Feature[]; + features?: Feature[]; + org?: ReturnType; }): AutumnContext => ({ features, - }) as AutumnContext; - + org: org ?? createMockOrg(), + apiVersion: new ApiVersionClass(ApiVersion.V1_2), + env: AppEnv.Sandbox, + }) as unknown as AutumnContext; diff --git a/server/tests/utils/mockUtils/cusEntitlementMocks.ts b/server/tests/utils/mockUtils/cusEntitlementMocks.ts index 1aa360a85..2de41359c 100644 --- a/server/tests/utils/mockUtils/cusEntitlementMocks.ts +++ b/server/tests/utils/mockUtils/cusEntitlementMocks.ts @@ -8,6 +8,7 @@ import { createMockEntitlement } from "./entitlementMocks"; export const createMockCusEntitlement = ({ id, + entitlementId, featureId, internalFeatureId, featureName, @@ -22,6 +23,7 @@ export const createMockCusEntitlement = ({ entityFeatureId = null, }: { id?: string; + entitlementId?: string; featureId: string; internalFeatureId?: string; featureName: string; @@ -34,32 +36,36 @@ export const createMockCusEntitlement = ({ nextResetAt?: number | null; entities?: Record | null; entityFeatureId?: string | null; -}): FullCustomerEntitlement => ({ - id: id ?? `cus_ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, - internal_customer_id: "cus_internal", - internal_feature_id: internalFeatureId ?? `internal_${featureId}`, - customer_id: "cus_test", - feature_id: featureId, - customer_product_id: "cus_prod_test", - entitlement_id: `ent_${featureId}`, - created_at: Date.now(), - unlimited: false, - balance, - additional_balance: 0, - usage_allowed: usageAllowed, - next_reset_at: nextResetAt, - adjustment: 0, - entities, - entitlement: createMockEntitlement({ - featureId, - internalFeatureId, - featureName, - allowance, - featureType, - interval, - intervalCount, - entityFeatureId, - }), - replaceables: [], - rollovers: [], -}); +}): FullCustomerEntitlement => { + const entId = entitlementId ?? `ent_${featureId}`; + return { + id: id ?? `cus_ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, + internal_customer_id: "cus_internal", + internal_feature_id: internalFeatureId ?? `internal_${featureId}`, + customer_id: "cus_test", + feature_id: featureId, + customer_product_id: "cus_prod_test", + entitlement_id: entId, + created_at: Date.now(), + unlimited: false, + balance, + additional_balance: 0, + usage_allowed: usageAllowed, + next_reset_at: nextResetAt, + adjustment: 0, + entities, + entitlement: createMockEntitlement({ + id: entId, + featureId, + internalFeatureId, + featureName, + allowance, + featureType, + interval, + intervalCount, + entityFeatureId, + }), + replaceables: [], + rollovers: [], + }; +}; diff --git a/server/tests/utils/mockUtils/cusProductMocks.ts b/server/tests/utils/mockUtils/cusProductMocks.ts index 05cf6f41a..c0940373b 100644 --- a/server/tests/utils/mockUtils/cusProductMocks.ts +++ b/server/tests/utils/mockUtils/cusProductMocks.ts @@ -5,24 +5,35 @@ import { type FullCusProduct, type FullCustomerEntitlement, type FullCustomerPrice, + type FullProduct, } from "@autumn/shared"; -import { createMockProduct } from "./productMocks"; +import { createMockFullProduct } from "./productMocks"; export const createMockCustomerProduct = ({ + id = "cus_prod_test", + productId = "prod_test", customerEntitlements = [], customerPrices = [], options = [], + subscriptionIds = [], + internalEntityId, + product, }: { + id?: string; + productId?: string; customerEntitlements?: FullCustomerEntitlement[]; customerPrices?: FullCustomerPrice[]; options?: FeatureOptions[]; + subscriptionIds?: string[]; + internalEntityId?: string; + product?: FullProduct; }): FullCusProduct => ({ - id: "cus_prod_test", - internal_product_id: "prod_internal", - product_id: "prod_test", + id, + internal_product_id: `internal_${productId}`, + product_id: productId, internal_customer_id: "cus_internal", customer_id: "cus_test", - internal_entity_id: null, + internal_entity_id: internalEntityId ?? null, entity_id: null, created_at: Date.now(), status: CusProductStatus.Active, @@ -34,13 +45,13 @@ export const createMockCustomerProduct = ({ options, free_trial_id: null, collection_method: CollectionMethod.ChargeAutomatically, - subscription_ids: [], + subscription_ids: subscriptionIds, scheduled_ids: [], quantity: 1, api_semver: null, is_custom: false, customer_prices: customerPrices, customer_entitlements: customerEntitlements, - product: createMockProduct(), + product: product ?? (createMockFullProduct({ id: productId }) as FullProduct), free_trial: null, }); diff --git a/server/tests/utils/mockUtils/customerMocks.ts b/server/tests/utils/mockUtils/customerMocks.ts new file mode 100644 index 000000000..1761431eb --- /dev/null +++ b/server/tests/utils/mockUtils/customerMocks.ts @@ -0,0 +1,35 @@ +import type { FullCusProduct, FullCustomer } from "@autumn/shared"; +import { AppEnv } from "@autumn/shared"; +import type Stripe from "stripe"; + +export const createMockFullCustomer = ({ + customerProducts = [], +}: { + customerProducts?: FullCusProduct[]; +}): FullCustomer => ({ + id: "cus_test", + name: "Test Customer", + email: "test@example.com", + fingerprint: null, + internal_id: "cus_internal_test", + org_id: "org_test", + created_at: Date.now(), + env: AppEnv.Sandbox, + processor: { type: "stripe", id: "cus_stripe_test" }, + processors: null, + metadata: {}, + customer_products: customerProducts, + entities: [], +}); + +export const createMockStripeCustomer = ({ + id = "cus_stripe_test", +}: { + id?: string; +} = {}): Stripe.Customer => + ({ + id, + object: "customer", + email: "test@example.com", + name: "Test Customer", + }) as Stripe.Customer; diff --git a/server/tests/utils/mockUtils/entitlementMocks.ts b/server/tests/utils/mockUtils/entitlementMocks.ts index ea293dde9..d661a7523 100644 --- a/server/tests/utils/mockUtils/entitlementMocks.ts +++ b/server/tests/utils/mockUtils/entitlementMocks.ts @@ -2,6 +2,7 @@ import { AllowanceType, type EntInterval, FeatureType } from "@autumn/shared"; import { createMockFeature } from "./featureMocks"; export const createMockEntitlement = ({ + id, featureId, internalFeatureId, featureName, @@ -11,6 +12,7 @@ export const createMockEntitlement = ({ intervalCount = 1, entityFeatureId = null, }: { + id?: string; featureId: string; internalFeatureId?: string; featureName: string; @@ -20,7 +22,7 @@ export const createMockEntitlement = ({ intervalCount?: number; entityFeatureId?: string | null; }) => ({ - id: `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, + id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, created_at: Date.now(), internal_feature_id: internalFeatureId ?? `internal_${featureId}`, internal_product_id: "prod_internal", diff --git a/server/tests/utils/mockUtils/priceMocks.ts b/server/tests/utils/mockUtils/priceMocks.ts index 95b1b2cc7..e27b4cc8b 100644 --- a/server/tests/utils/mockUtils/priceMocks.ts +++ b/server/tests/utils/mockUtils/priceMocks.ts @@ -12,11 +12,15 @@ export const createMockPrepaidPrice = ({ featureId, internalFeatureId, billingUnits = 1, + stripePriceId, + entitlementId, }: { id: string; featureId: string; internalFeatureId?: string; billingUnits?: number; + stripePriceId?: string; + entitlementId?: string; }): Price => ({ id, @@ -25,7 +29,7 @@ export const createMockPrepaidPrice = ({ created_at: Date.now(), billing_type: "usage_in_advance", is_custom: false, - entitlement_id: null, + entitlement_id: entitlementId ?? `ent_${featureId}`, proration_config: null, config: { type: PriceType.Usage, @@ -35,10 +39,17 @@ export const createMockPrepaidPrice = ({ feature_id: featureId, usage_tiers: [{ to: Infinite, amount: 10 }], interval: BillingInterval.Month, + stripe_price_id: stripePriceId ?? `stripe_price_${id}`, }, }) as Price; -export const createMockFixedPrice = ({ id }: { id: string }): Price => +export const createMockFixedPrice = ({ + id, + stripePriceId, +}: { + id: string; + stripePriceId?: string; +}): Price => ({ id, internal_product_id: "prod_internal", @@ -52,6 +63,107 @@ export const createMockFixedPrice = ({ id }: { id: string }): Price => type: PriceType.Fixed, amount: 100, interval: BillingInterval.Month, + stripe_price_id: stripePriceId ?? `stripe_price_${id}`, + }, + }) as Price; + +export const createMockConsumablePrice = ({ + id, + featureId, + internalFeatureId, + stripePriceId, + stripeEmptyPriceId, + entitlementId, +}: { + id: string; + featureId: string; + internalFeatureId?: string; + stripePriceId?: string; + stripeEmptyPriceId?: string; + entitlementId?: string; +}): Price => + ({ + id, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "usage_in_arrear", + is_custom: false, + entitlement_id: entitlementId ?? `ent_${featureId}`, + proration_config: null, + config: { + type: PriceType.Usage, + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + internal_feature_id: internalFeatureId ?? `internal_${featureId}`, + feature_id: featureId, + usage_tiers: [{ to: Infinite, amount: 1 }], + interval: BillingInterval.Month, + stripe_price_id: stripePriceId ?? `stripe_price_${id}`, + stripe_empty_price_id: stripeEmptyPriceId ?? `stripe_empty_price_${id}`, + }, + }) as Price; + +export const createMockAllocatedPrice = ({ + id, + featureId, + internalFeatureId, + stripePriceId, + stripeEmptyPriceId, + entitlementId, +}: { + id: string; + featureId: string; + internalFeatureId?: string; + stripePriceId?: string; + stripeEmptyPriceId?: string; + entitlementId?: string; +}): Price => + ({ + id, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "in_arrear_prorated", + is_custom: false, + entitlement_id: entitlementId ?? `ent_${featureId}`, + proration_config: null, + config: { + type: PriceType.Usage, + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + internal_feature_id: internalFeatureId ?? `internal_${featureId}`, + feature_id: featureId, + usage_tiers: [{ to: Infinite, amount: 10 }], + interval: BillingInterval.Month, + stripe_price_id: stripePriceId ?? `stripe_price_${id}`, + stripe_empty_price_id: stripeEmptyPriceId ?? `stripe_empty_price_${id}`, + }, + }) as Price; + +export const createMockOneOffPrice = ({ + id, + stripePriceId, + amount = 100, +}: { + id: string; + stripePriceId?: string; + amount?: number; +}): Price => + ({ + id, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "one_off", + is_custom: false, + entitlement_id: null, + proration_config: null, + config: { + type: PriceType.Fixed, + amount, + interval: BillingInterval.OneOff, + stripe_price_id: stripePriceId ?? `stripe_price_${id}`, }, }) as Price; diff --git a/server/tests/utils/mockUtils/productMocks.ts b/server/tests/utils/mockUtils/productMocks.ts index 39142f0f0..1e9ae9749 100644 --- a/server/tests/utils/mockUtils/productMocks.ts +++ b/server/tests/utils/mockUtils/productMocks.ts @@ -1,7 +1,13 @@ import { AppEnv, type FullProduct, type Price } from "@autumn/shared"; -export const createMockProduct = () => ({ - id: "prod_test", +export const createMockProduct = ({ + id = "prod_test", + stripeProductId, +}: { + id?: string; + stripeProductId?: string; +} = {}) => ({ + id, name: "Test Product", description: null, is_add_on: false, @@ -9,21 +15,25 @@ export const createMockProduct = () => ({ version: 1, group: "test_group", env: AppEnv.Sandbox, - internal_id: "prod_internal", + internal_id: `internal_${id}`, org_id: "org_test", created_at: Date.now(), - processor: null, + processor: stripeProductId ? { type: "stripe", id: stripeProductId } : null, base_variant_id: null, archived: false, }); export const createMockFullProduct = ({ - prices, + id = "prod_test", + prices = [], + stripeProductId, }: { - prices: Price[]; + id?: string; + prices?: Price[]; + stripeProductId?: string; }): FullProduct => ({ - id: "prod_test", + id, name: "Test Product", description: null, is_add_on: false, @@ -31,10 +41,10 @@ export const createMockFullProduct = ({ version: 1, group: "test_group", env: AppEnv.Sandbox, - internal_id: "prod_internal", + internal_id: `internal_${id}`, org_id: "org_test", created_at: Date.now(), - processor: null, + processor: stripeProductId ? { type: "stripe", id: stripeProductId } : null, base_variant_id: null, archived: false, prices, diff --git a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts b/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts index b26a9ec82..6260a27ca 100644 --- a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts +++ b/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts @@ -1,3 +1,4 @@ +import { CreateFreeTrialSchema } from "@models/productModels/freeTrialModels/freeTrialModels"; import { z } from "zod/v4"; import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels"; import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels"; @@ -19,8 +20,10 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({ enable_product_immediately: z.boolean().optional(), finalize_invoice: z.boolean().optional(), - // Reset billing cycle anchor? + // New items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override) + free_trial: CreateFreeTrialSchema.nullable().optional(), + reset_billing_cycle_anchor: z.boolean().optional(), new_billing_subscription: z.boolean().optional(), prorate_billing: z.boolean().optional(), diff --git a/shared/index.ts b/shared/index.ts index f1f2a1538..97feece8c 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -193,9 +193,9 @@ export * from "./utils/planFeatureUtils/planToItems.js"; export * from "./utils/planFeatureUtils/planToProductV2.js"; export * from "./utils/productDisplayUtils/sortProductItems.js"; export * from "./utils/productDisplayUtils.js"; -export * from "./utils/productUtils/convertUtils.js"; +export * from "./utils/productUtils/convertProductUtils.js"; export * from "./utils/productUtils/priceToInvoiceAmount.js"; -export * from "./utils/productUtils.js"; +export * from "./utils/productUtils/productUtils.js"; export * from "./utils/productV2Utils/compareProductUtils.ts/compareProductUtils.js"; export * from "./utils/productV2Utils/productItemUtils/convertItemUtils.js"; export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js"; diff --git a/shared/models/billingModels/initFullCustomerProductContext.ts b/shared/models/billingModels/initFullCustomerProductContext.ts index e51ac1309..f62fac0cb 100644 --- a/shared/models/billingModels/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/initFullCustomerProductContext.ts @@ -1,4 +1,5 @@ import type { ExistingRollover } from "@models/billingModels/existingRollovers"; +import type { FreeTrial } from "@models/productModels/freeTrialModels/freeTrialModels"; import type { ApiVersion } from "../../api/versionUtils/ApiVersion"; import type { FullCustomer } from "../cusModels/fullCusModel"; import type { @@ -15,15 +16,20 @@ export interface InitFullCustomerProductContext { featureQuantities: FeatureOptions[]; // For customer entitlements + resetCycleAnchor: number | "now"; // Unix timestamp of the next existingUsages?: ExistingUsages; existingRollovers?: ExistingRollover[]; + + // Others + freeTrial: FreeTrial | null; + trialEndsAt?: number; + now: number; // milliseconds since epoch } export interface InitFullCustomerProductOptions { subscriptionId?: string; subscriptionScheduleId?: string; isCustom?: boolean; - resetCycleAnchor?: number; // Unix timestamp of the next canceledAt?: number; status?: CusProductStatus; // Used for scheduling product startsAt?: number; // Used for scheduling product diff --git a/shared/models/cusModels/fullCusModel.ts b/shared/models/cusModels/fullCusModel.ts index 7c23e00b9..c6425d4b9 100644 --- a/shared/models/cusModels/fullCusModel.ts +++ b/shared/models/cusModels/fullCusModel.ts @@ -3,12 +3,34 @@ import { z } from "zod/v4"; import { CusProductSchema, type FullCusProduct, + FullCusProductSchema, } from "../cusProductModels/cusProductModels.js"; import type { Event } from "../eventModels/eventTable.js"; -import type { Subscription } from "../subModels/subModels.js"; +import { + type Subscription, + SubscriptionSchema, +} from "../subModels/subModels.js"; import { type Customer, CustomerSchema } from "./cusModels.js"; -import type { Entity } from "./entityModels/entityModels.js"; -import type { Invoice } from "./invoiceModels/invoiceModels.js"; +import { type Entity, EntitySchema } from "./entityModels/entityModels.js"; +import { type Invoice, InvoiceSchema } from "./invoiceModels/invoiceModels.js"; + +export const FullCustomerSchema = CustomerSchema.extend({ + customer_products: z.array(FullCusProductSchema), + entities: z.array(EntitySchema), + subscriptions: z.array(SubscriptionSchema).optional(), + entity: EntitySchema.optional(), + + trials_used: z + .array( + z.object({ + product_id: z.string(), + customer_id: z.string(), + fingerprint: z.string(), + }), + ) + .optional(), + invoices: z.array(InvoiceSchema).optional(), +}); export type FullCustomer = Customer & { customer_products: FullCusProduct[]; diff --git a/shared/package.json b/shared/package.json index ed5645632..6b66edcce 100644 --- a/shared/package.json +++ b/shared/package.json @@ -22,16 +22,17 @@ "db:studio": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit studio --config drizzle.config.ts" }, "dependencies": { - "date-fns": "^4.1.0", "@date-fns/utc": "catalog:", + "@owpz/ksuid": "^25.7.20", + "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "drizzle-zod": "^0.8.2", + "stripe": "catalog:", "yaml": "^2.8.1", - "zod-openapi": "^5.4.1", - "stripe": "catalog:" + "zod-openapi": "^5.4.1" }, "peerDependencies": { "zod": "^3.25.23" @@ -39,7 +40,6 @@ "devDependencies": { "@types/bun": "latest", "@types/node": "^24.0.3", - "cross-env": "^7.0.3", "nodemon": "^3.1.7", "tsx": "^4.19.4", diff --git a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts index f896aa0da..0561ed933 100644 --- a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts +++ b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts @@ -23,14 +23,14 @@ export const getCycleEnd = ({ intervalCount = 1, now, }: { - anchor: number; + anchor: number | "now"; interval: BillingInterval | EntInterval; intervalCount?: number; now: number; // milliseconds since epoch }): number => { // EDGE CASE: anchor might be slightly before now due to network latency. - const anchorDate = new UTCDate(anchor); + const anchorDate = anchor === "now" ? new UTCDate(now) : new UTCDate(anchor); const nowDate = new UTCDate(now); // For now, only handle monthly intervals @@ -70,7 +70,7 @@ export const getCycleEnd = ({ const printLogs = false; if (printLogs) { - console.log(`anchor: ${formatMs(anchor)}`); + console.log(`anchor: ${anchor === "now" ? "now" : formatMs(anchor)}`); console.log(`now: ${formatMs(now)}`); console.log(`cycles passed: ${cyclesPassed}`); console.log(`next cycle end: ${formatMs(nextCycleEnd.getTime())}`); diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 3535d9ef8..35185dac3 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -1,7 +1,11 @@ export * from "./cycleUtils/getCycleEnd"; export * from "./cycleUtils/getCycleStart"; + +// Interval utils +export * from "./intervalUtils/addDuration"; export * from "./intervalUtils/intervalArithmetic"; +// Invoicing utils export * from "./invoicingUtils/cusProductToArrearLineItems"; export * from "./invoicingUtils/cusProductToLineItems"; export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem"; diff --git a/shared/utils/billingUtils/intervalUtils/addDuration.ts b/shared/utils/billingUtils/intervalUtils/addDuration.ts new file mode 100644 index 000000000..140655227 --- /dev/null +++ b/shared/utils/billingUtils/intervalUtils/addDuration.ts @@ -0,0 +1,23 @@ +import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums"; +import { addDays, addMonths, addYears } from "date-fns"; + +export const addDuration = ({ + now, + durationType, + durationLength = 1, +}: { + now: number; + durationType: FreeTrialDuration; + durationLength?: number; +}) => { + switch (durationType) { + case FreeTrialDuration.Day: + return addDays(now, durationLength).getTime(); + case FreeTrialDuration.Month: + return addMonths(now, durationLength).getTime(); + case FreeTrialDuration.Year: + return addYears(now, durationLength).getTime(); + default: + throw new Error(`Invalid duration type: ${durationType}`); + } +}; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts index bd922ea35..3544e5f96 100644 --- a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/lineItemToPeriodDescription.ts @@ -8,6 +8,8 @@ export const lineItemToPeriodDescription = ({ }): string => { const { now, billingPeriod, billingTiming } = context; + if (!billingPeriod) return ""; + // In-arrear: billing for past usage (start → now) // In-advance: billing for future usage (now → end) const periodStart = billingTiming === "in_arrear" ? billingPeriod.start : now; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 10369a24a..e5badda80 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -2,10 +2,10 @@ import { InternalError } from "../../../../api/errors/base/InternalError"; import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext"; import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { cusEntToPrepaidQuantity } from "../../../cusEntUtils/balanceUtils/cusEntToPrepaidQuantity"; +import { cusEntToCusPrice } from "../../../cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; import { cusEntToStripeIds } from "../../../cusEntUtils/convertCusEntUtils/cusEntToStripeIds"; import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage"; import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage"; -import { cusEntToCusPrice } from "../../../productUtils/convertUtils"; import { isConsumablePrice, isPrepaidPrice, diff --git a/shared/utils/common/formatUtils/formatUnix.ts b/shared/utils/common/formatUtils/formatUnix.ts index cf92395ca..24d5c6a6e 100644 --- a/shared/utils/common/formatUtils/formatUnix.ts +++ b/shared/utils/common/formatUtils/formatUnix.ts @@ -11,9 +11,12 @@ export const formatMsToDate = ( }; export const formatMs = ( - unixDate?: number | null, + unixDate?: number | null | "now", options?: { withTimezone?: boolean; excludeSeconds?: boolean }, ) => { + if (unixDate === "now") { + return "now"; + } if (!unixDate) { return "undefined unix date"; } diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts index 5392b27f2..1622c3b8b 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts @@ -1,7 +1,7 @@ import { Decimal } from "decimal.js"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../../productUtils/priceUtils/classifyPriceUtils.js"; +import { cusEntToCusPrice } from "../convertCusEntUtils/cusEntToCusPrice.js"; export const cusEntToPrepaidQuantity = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts index 30866ad53..3a21cd444 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts @@ -1,8 +1,6 @@ import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { - cusEntToCusPrice, - entToOptions, -} from "../../productUtils/convertUtils"; +import { entToOptions } from "../../productUtils/convertProductUtils"; +import { cusEntToCusPrice } from "../convertCusEntUtils/cusEntToCusPrice"; import { getStartingBalance } from "../getStartingBalance"; export const cusEntToStartingBalance = ({ diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts index 1c220d1c2..4d74df47a 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts @@ -1,7 +1,7 @@ import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { FeatureUsageType } from "../../../models/featureModels/featureEnums"; -import { cusEntToCusPrice } from "../../productUtils/convertUtils"; import { nullish } from "../../utils"; +import { cusEntToCusPrice } from "../convertCusEntUtils/cusEntToCusPrice"; export const cusEntToUsageAllowed = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts index dce503f51..d43e6713f 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts @@ -1,9 +1,9 @@ import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; -import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; import { getBillingType } from "../../productUtils/priceUtils.js"; import { nullish, sumValues } from "../../utils.js"; import { getCusEntBalance } from "../balanceUtils.js"; +import { cusEntToCusPrice } from "../convertCusEntUtils/cusEntToCusPrice.js"; import { cusEntToPrepaidQuantity } from "./cusEntToPrepaidQuantity.js"; export const cusEntsToPurchasedBalance = ({ @@ -58,15 +58,3 @@ export const cusEntsToPurchasedBalance = ({ cusEnts.map((cusEnt) => getPurchasedBalance({ cusEnt, entityId })), ); }; - -// // Purchased balance is how much was prepaid -// const cusProduct = cusEnt.customer_product; -// const options = entToOptions({ -// ent: cusEnt.entitlement, -// options: cusProduct.options, -// }); - -// const quantity = options?.quantity || 0; -// const quantityWithBillingUnits = new Decimal(quantity) -// .mul(billingUnits) -// .toNumber(); diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index a8f64ca3e..4154ee20d 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -5,9 +5,9 @@ import type { import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { FeatureType } from "../../models/featureModels/featureEnums"; import { AllowanceType } from "../../models/productModels/entModels/entModels"; -import { cusEntToCusPrice } from "../productUtils/convertUtils"; import { isAllocatedPrice } from "../productUtils/priceUtils/classifyPriceUtils"; import { notNullish } from "../utils"; +import { cusEntToCusPrice } from "./convertCusEntUtils/cusEntToCusPrice"; export const isUnlimitedCusEnt = (cusEnt: FullCustomerEntitlement) => { return cusEnt.entitlement.allowance_type === AllowanceType.Unlimited; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 4d9cb757b..72322b9b4 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -3,11 +3,9 @@ import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBal import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js"; -import { - cusEntToCusPrice, - entToOptions, -} from "../productUtils/convertUtils.js"; +import { entToOptions } from "../productUtils/convertProductUtils.js"; import { getCusEntBalance } from "./balanceUtils.js"; +import { cusEntToCusPrice } from "./convertCusEntUtils/cusEntToCusPrice.js"; import { getRolloverFields } from "./getRolloverFields.js"; import { getStartingBalance } from "./getStartingBalance.js"; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.ts new file mode 100644 index 000000000..ba43cbcff --- /dev/null +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.ts @@ -0,0 +1,19 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCustomerPrice } from "../../../models/cusProductModels/cusPriceModels/cusPriceModels"; + +export const cusEntToCusPrice = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const cusProduct = cusEnt.customer_product; + const cusPrices = cusProduct.customer_prices; + return cusPrices.find((cusPrice: FullCustomerPrice) => { + const productMatch = + cusPrice.customer_product_id === cusEnt.customer_product_id; + + const entMatch = cusPrice.price.entitlement_id === cusEnt.entitlement.id; + + return productMatch && entMatch; + }); +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts index 03150ab40..514c268f9 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToStripeIds.ts @@ -1,6 +1,6 @@ import { InternalError } from "../../../api/errors/base/InternalError"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { cusEntToCusPrice } from "../../productUtils/convertUtils"; +import { cusEntToCusPrice } from "./cusEntToCusPrice"; export const cusEntToStripeIds = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index cb188d7b7..56b063523 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -3,8 +3,8 @@ import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDe import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; -import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../productUtils/priceUtils/classifyPriceUtils.js"; +import { cusEntToCusPrice } from "./convertCusEntUtils/cusEntToCusPrice.js"; export const formatCusEnt = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts b/shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts index 3c2ddea5a..20d07bbfc 100644 --- a/shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts +++ b/shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts @@ -1,5 +1,5 @@ import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { entToOptions } from "../../productUtils/convertUtils"; +import { entToOptions } from "../../productUtils/convertProductUtils"; export const cusEntToOptions = ({ cusEnt, diff --git a/shared/utils/cusProductUtils/classifyCusProduct.ts b/shared/utils/cusProductUtils/classifyCusProduct.ts index 67ceeb1ad..5dbfaa46d 100644 --- a/shared/utils/cusProductUtils/classifyCusProduct.ts +++ b/shared/utils/cusProductUtils/classifyCusProduct.ts @@ -2,7 +2,7 @@ import type { FullCusProduct } from "@models/cusProductModels/cusProductModels.j import { isFreeProduct, isOneOffProduct, -} from "../productUtils/classifyProductUtils"; +} from "../productUtils/classifyProduct/classifyProductUtils"; import { notNullish, nullish } from "../utils"; import { cusProductToPrices } from "./convertCusProduct"; import { ACTIVE_STATUSES } from "./cusProductConstants"; @@ -79,6 +79,16 @@ export const isCusProductOngoing = ({ return isActive && isNotOneOff; }; +export const isCustomerProductOnStripeSubscription = ({ + customerProduct, + stripeSubscriptionId, +}: { + customerProduct: FullCusProduct; + stripeSubscriptionId: string; +}) => { + return customerProduct.subscription_ids?.includes(stripeSubscriptionId); +}; + // Note, this does not CONFIRM that the subscription is active (might be canceled in Stripe...) export const cusProductHasSubscription = ({ cusProduct, diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 646c8a405..3fcfcd0a8 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -20,6 +20,7 @@ export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToGrantedBa export * from "./cusEntUtils/balanceUtils.js"; export * from "./cusEntUtils/classifyCusEntUtils.js"; export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js"; +export * from "./cusEntUtils/convertCusEntUtils/cusEntToCusPrice.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; export * from "./cusEntUtils/filterCusEntUtils.js"; @@ -54,12 +55,15 @@ export * from "./intervalUtils/priceIntervalUtils.js"; export * from "./intervalUtils/priceIntervalUtils.js"; // Org utils export * from "./orgUtils/convertOrgUtils.js"; -export * from "./productUtils/classifyProductUtils.js"; +export * from "./productUtils/classifyProduct/classifyProductUtils.js"; +export * from "./productUtils/classifyProduct/isProductPaidAndRecurring.js"; // Product utils -export * from "./productUtils/convertUtils.js"; +export * from "./productUtils/convertProductUtils.js"; export * from "./productUtils/entUtils/classifyEntUtils.js"; export * from "./productUtils/entUtils/entUtils.js"; export * from "./productUtils/entUtils/formatEntUtils.js"; +export * from "./productUtils/freeTrialUtils/initFreeTrial.js"; +export * from "./productUtils/freeTrialUtils.js"; export * from "./productUtils/isProductUpgrade.js"; export * from "./productUtils/priceUtils/classifyPriceUtils.js"; export * from "./productUtils/priceUtils/convertAmountUtils.js"; diff --git a/shared/utils/productUtils/classifyProductUtils.ts b/shared/utils/productUtils/classifyProduct/classifyProductUtils.ts similarity index 71% rename from shared/utils/productUtils/classifyProductUtils.ts rename to shared/utils/productUtils/classifyProduct/classifyProductUtils.ts index 1d6e48378..9362ea533 100644 --- a/shared/utils/productUtils/classifyProductUtils.ts +++ b/shared/utils/productUtils/classifyProduct/classifyProductUtils.ts @@ -1,9 +1,9 @@ import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; -import { type FullProduct, nullish } from "../../index.js"; -import type { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; -import type { UsagePriceConfig } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; -import { PriceType } from "../../models/productModels/priceModels/priceEnums.js"; -import type { Price } from "../../models/productModels/priceModels/priceModels.js"; +import { type FullProduct, nullish } from "../../../index.js"; +import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; +import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { PriceType } from "../../../models/productModels/priceModels/priceEnums.js"; +import type { Price } from "../../../models/productModels/priceModels/priceModels.js"; // TODO: Write unit tests for these functions (?) diff --git a/shared/utils/productUtils/classifyProduct/isProductPaidAndRecurring.ts b/shared/utils/productUtils/classifyProduct/isProductPaidAndRecurring.ts new file mode 100644 index 000000000..4dbe6172d --- /dev/null +++ b/shared/utils/productUtils/classifyProduct/isProductPaidAndRecurring.ts @@ -0,0 +1,12 @@ +import type { FullProduct } from "@models/productModels/productModels"; +import { + isFreeProduct, + isOneOffProduct, +} from "@utils/productUtils/classifyProduct/classifyProductUtils"; + +export const isProductPaidAndRecurring = (product: FullProduct) => { + return ( + isOneOffProduct({ prices: product.prices }) && + !isFreeProduct({ prices: product.prices }) + ); +}; diff --git a/shared/utils/productUtils/convertProductUtils.ts b/shared/utils/productUtils/convertProductUtils.ts new file mode 100644 index 000000000..d4fb9052b --- /dev/null +++ b/shared/utils/productUtils/convertProductUtils.ts @@ -0,0 +1,57 @@ +import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; +import type { + Entitlement, + EntitlementWithFeature, +} from "../../models/productModels/entModels/entModels.js"; +import type { Price } from "../../models/productModels/priceModels/priceModels.js"; +import type { FullProduct } from "../../models/productModels/productModels.js"; + +export const entToPrice = ({ + ent, + prices, +}: { + ent: Entitlement; + prices: Price[]; +}) => { + return prices.find( + (price) => + price.entitlement_id === ent.id && + price.internal_product_id === ent.internal_product_id, + ); +}; + +export const priceToEnt = ({ + price, + entitlements, +}: { + price: Price; + entitlements: EntitlementWithFeature[]; +}) => { + return entitlements.find( + (ent) => + ent.id === price.entitlement_id && + ent.internal_product_id === price.internal_product_id, + ); +}; + +export const entToOptions = ({ + ent, + options, +}: { + ent: Entitlement; + options: FeatureOptions[]; +}) => { + return options.find( + (option) => option.internal_feature_id === ent.internal_feature_id, + ); +}; + +export const productToEnt = ({ + product, + featureId, +}: { + product: FullProduct; + featureId: string; +}) => { + return product.entitlements.find((ent) => ent.feature.id === featureId); +}; diff --git a/shared/utils/productUtils/convertUtils.ts b/shared/utils/productUtils/convertUtils.ts deleted file mode 100644 index b179ad4f2..000000000 --- a/shared/utils/productUtils/convertUtils.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels.js"; -import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; -import type { - Entitlement, - EntitlementWithFeature, -} from "../../models/productModels/entModels/entModels.js"; -import type { Price } from "../../models/productModels/priceModels/priceModels.js"; -import type { FullProduct } from "../../models/productModels/productModels.js"; - -// export const getEntRelatedPrice = ( -// entitlement: Entitlement, -// prices: Price[], -// allowFeatureMatch = false -// ) => { -// return prices.find((price) => { -// if (price.config?.type === PriceType.Fixed) { -// return false; -// } - -// let config = price.config as UsagePriceConfig; - -// if (allowFeatureMatch) { -// return entitlement.internal_feature_id == config.internal_feature_id; -// } - -// let entIdMatch = entitlement.id == price.entitlement_id; -// let productIdMatch = -// entitlement.internal_product_id == price.internal_product_id; -// return entIdMatch && productIdMatch; -// }); -// }; - -export const entToPrice = ({ - ent, - prices, -}: { - ent: Entitlement; - prices: Price[]; -}) => { - return prices.find( - (price) => - price.entitlement_id === ent.id && - price.internal_product_id === ent.internal_product_id, - ); -}; - -export const priceToEnt = ({ - price, - entitlements, -}: { - price: Price; - entitlements: EntitlementWithFeature[]; -}) => { - return entitlements.find( - (ent) => - ent.id === price.entitlement_id && - ent.internal_product_id === price.internal_product_id, - ); -}; - -export const entToOptions = ({ - ent, - options, -}: { - ent: Entitlement; - options: FeatureOptions[]; -}) => { - return options.find( - (option) => option.internal_feature_id === ent.internal_feature_id, - ); -}; - -export const cusEntToCusPrice = ({ - cusEnt, -}: { - cusEnt: FullCusEntWithFullCusProduct; -}) => { - const cusProduct = cusEnt.customer_product; - const cusPrices = cusProduct.customer_prices; - return cusPrices.find((cusPrice: FullCustomerPrice) => { - const productMatch = - cusPrice.customer_product_id === cusEnt.customer_product_id; - - const entMatch = cusPrice.price.entitlement_id === cusEnt.entitlement.id; - - return productMatch && entMatch; - }); -}; - -export const productToEnt = ({ - product, - featureId, -}: { - product: FullProduct; - featureId: string; -}) => { - return product.entitlements.find((ent) => ent.feature.id === featureId); -}; diff --git a/shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts b/shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts new file mode 100644 index 000000000..68c88c31b --- /dev/null +++ b/shared/utils/productUtils/freeTrialUtils/initFreeTrial.ts @@ -0,0 +1,28 @@ +import { + type CreateFreeTrial, + CreateFreeTrialSchema, + type FreeTrial, +} from "@models/productModels/freeTrialModels/freeTrialModels"; +import { generateId } from "@utils/utils"; + +export const initFreeTrial = ({ + freeTrialParams, + internalProductId, + isCustom = false, +}: { + freeTrialParams: CreateFreeTrial; + internalProductId: string; + isCustom?: boolean; +}): FreeTrial => { + const parsedFreeTrialParams = CreateFreeTrialSchema.parse(freeTrialParams); + + return { + ...parsedFreeTrialParams, + id: generateId("ft"), + created_at: Date.now(), + internal_product_id: internalProductId, + is_custom: isCustom, + }; +}; + +// card_required: freeTrial.card_required ?? true, diff --git a/shared/utils/productUtils/isProductUpgrade.ts b/shared/utils/productUtils/isProductUpgrade.ts index fee48f702..d6c852f27 100644 --- a/shared/utils/productUtils/isProductUpgrade.ts +++ b/shared/utils/productUtils/isProductUpgrade.ts @@ -7,7 +7,7 @@ import { intervalsSame, } from "../intervalUtils/priceIntervalUtils"; import { nullish } from "../utils"; -import { isFreeProduct } from "./classifyProductUtils"; +import { isFreeProduct } from "./classifyProduct/classifyProductUtils"; import { isConsumablePrice } from "./priceUtils/classifyPriceUtils"; export const isProductUpgrade = ({ diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index 2c9077d45..0709a78a6 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -2,7 +2,7 @@ import type { Feature } from "@models/featureModels/featureModels"; import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import type { Price } from "@models/productModels/priceModels/priceModels"; -import { priceToEnt } from "@utils/productUtils/convertUtils"; +import { priceToEnt } from "@utils/productUtils/convertProductUtils"; export const priceToFeature = ({ price, diff --git a/shared/utils/productUtils.ts b/shared/utils/productUtils/productUtils.ts similarity index 95% rename from shared/utils/productUtils.ts rename to shared/utils/productUtils/productUtils.ts index 7409f4f2d..3fc5c2949 100644 --- a/shared/utils/productUtils.ts +++ b/shared/utils/productUtils/productUtils.ts @@ -6,12 +6,12 @@ import { type Price, type ProductItem, type ProductV2, -} from "../index.js"; +} from "../../index.js"; import { isFeaturePriceItem, isPriceItem, -} from "./productV2Utils/productItemUtils/getItemType.js"; -import { nullish } from "./utils.js"; +} from "../productV2Utils/productItemUtils/getItemType.js"; +import { nullish } from "../utils.js"; export const isDefaultTrialV2 = ({ freeTrial, diff --git a/shared/utils/productV2Utils/mapToProductV2.ts b/shared/utils/productV2Utils/mapToProductV2.ts index 84f1c09e8..cf245b87f 100644 --- a/shared/utils/productV2Utils/mapToProductV2.ts +++ b/shared/utils/productV2Utils/mapToProductV2.ts @@ -4,7 +4,7 @@ import type { Price } from "../../models/productModels/priceModels/priceModels.j import type { FullProduct } from "../../models/productModels/productModels.js"; import type { ProductItem } from "../../models/productV2Models/productItemModels/productItemModels.js"; import type { ProductV2 } from "../../models/productV2Models/productV2Models.js"; -import { entToPrice, priceToEnt } from "../productUtils/convertUtils.js"; +import { entToPrice, priceToEnt } from "../productUtils/convertProductUtils.js"; import { toProductItem } from "./productItemUtils/mapToItem.js"; import { getItemFeatureType } from "./productItemUtils/productItemUtils.js"; diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index 62da38497..bc8bbd60f 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -1,3 +1,10 @@ +import { KSUID } from "@owpz/ksuid"; + +export const generateId = (prefix?: string): string => { + const id = KSUID.random().toString(); + return prefix ? `${prefix}_${id}` : id; +}; + export const nullish = ( value: T | null | undefined, ): value is null | undefined => {