diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts index bcb3a8c16..a6bdf19da 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts @@ -8,9 +8,10 @@ import { ApiCusFeatureSchema, CusExpand, cusEntToBalance, - cusEntToIncludedUsage, + cusEntToGrantedBalance, cusEntToKey, cusEntToMaxPurchase, + cusEntToPurchasedBalance, type Feature, FeatureType, getCusEntBalance, @@ -110,18 +111,6 @@ export const getApiCusFeature = ({ return getUnlimitedApiCusFeature({ apiFeature, cusEnts }); } - const totalBalanceWithRollovers = sumValues( - cusEnts - .map((cusEnt) => - cusEntToBalance({ - cusEnt, - entityId, - withRollovers: true, - }), - ) - .filter(notNullish), - ); - const totalAdjustment = sumValues( cusEnts.map((cusEnt) => { const { adjustment } = getCusEntBalance({ cusEnt, entityId }); @@ -142,20 +131,56 @@ export const getApiCusFeature = ({ cusEnts.map((cusEnt) => cusEntToMaxPurchase({ cusEnt })), ); - const totalIncludedUsage = sumValues( - cusEnts.map((cusEnt) => cusEntToIncludedUsage({ cusEnt, entityId })), - ); + // const totalIncludedUsage = sumValues( + // cusEnts.map((cusEnt) => cusEntToIncludedUsage({ cusEnt, entityId })), + // ); - const totalIncludedUsageWithRollovers = sumValues( + // const totalIncludedUsageWithRollovers = sumValues( + // cusEnts.map((cusEnt) => + // cusEntToIncludedUsage({ cusEnt, entityId, withRollovers: true }), + // ), + // ); + + // const totalUsage = new Decimal(totalIncludedUsageWithRollovers) + // .add(totalAdjustment) + // .sub(totalBalanceWithRollovers) + // .sub(totalUnused) + // .toNumber(); + + // 1. Granted balance + const totalGrantedBalanceWithRollovers = sumValues( cusEnts.map((cusEnt) => - cusEntToIncludedUsage({ cusEnt, entityId, withRollovers: true }), + cusEntToGrantedBalance({ cusEnt, entityId, withRollovers: true }), ), ); - const totalUsage = new Decimal(totalIncludedUsageWithRollovers) - .add(totalAdjustment) + // 2. Purchased balance + const totalPurchasedBalance = sumValues( + cusEnts.map((cusEnt) => cusEntToPurchasedBalance({ cusEnt })), + ); + + // 3. Current balance + const totalBalanceWithRollovers = sumValues( + cusEnts + .map((cusEnt) => + cusEntToBalance({ + cusEnt, + entityId, + withRollovers: true, + }), + ) + .filter(notNullish), + ); + const currentBalance = new Decimal(totalBalanceWithRollovers) + .add(totalUnused) + .toNumber(); + + // 4. Usage + const totalUsage = new Decimal(totalGrantedBalanceWithRollovers) + .add(totalPurchasedBalance) .sub(totalBalanceWithRollovers) .sub(totalUnused) + .sub(totalAdjustment) .toNumber(); const { interval, interval_count } = cusEntsToInterval({ cusEnts }); @@ -171,12 +196,18 @@ export const getApiCusFeature = ({ unlimited: false, - starting_balance: totalIncludedUsageWithRollovers, - balance: totalBalanceWithRollovers, - + granted_balance: totalGrantedBalanceWithRollovers, + purchased_balance: totalPurchasedBalance, + current_balance: currentBalance, usage: totalUsage, + // starting_balance: totalIncludedUsageWithRollovers, + // balance: totalBalanceWithRollovers, + + // usage: totalUsage, + resets_at: nextResetAt, + reset_interval: interval, reset_interval_count: interval_count !== 1 ? interval_count : undefined, diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index d0cfb3c5a..7f9b2a851 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -1,6 +1,5 @@ import { AffectedResource, - type ApiPlan, ApiVersion, CreatePlanParamsSchema, type CreateProductV2Params, @@ -95,15 +94,16 @@ export const createProduct = createRoute({ // body: CreateProductV2ParamsSchema, versionedBody: { latest: CreatePlanParamsSchema, - [ApiVersion.V1_1]: CreateProductV2ParamsSchema, + [ApiVersion.V1_2]: CreateProductV2ParamsSchema, }, resource: AffectedResource.Product, handler: async (c) => { const body = c.req.valid("json"); const ctx = c.get("ctx"); - // const query = c.req.valid("query"); - const v1_2Body = planToProductV2({ plan: body as ApiPlan }); + const v1_2Body = ( + ctx.apiVersion.gte(ApiVersion.V2) ? planToProductV2({ plan: body }) : body + ) as CreateProductV2Params; const { logger, org, features, env, db } = ctx; diff --git a/server/src/internal/products/productUtils/apiPlanUtils/planToProductV2.ts b/server/src/internal/products/productUtils/apiPlanUtils/planToProductV2.ts index 1a960116d..368b99c61 100644 --- a/server/src/internal/products/productUtils/apiPlanUtils/planToProductV2.ts +++ b/server/src/internal/products/productUtils/apiPlanUtils/planToProductV2.ts @@ -1,4 +1,5 @@ import { + type CreatePlanParams, type CreateProductV2Params, CreateProductV2ParamsSchema, convertPlanToItems, @@ -8,7 +9,7 @@ import type { ApiPlan } from "@shared/api/products/apiPlan.js"; export const planToProductV2 = ({ plan, }: { - plan: ApiPlan; + plan: ApiPlan | CreatePlanParams; }): CreateProductV2Params => { try { // Convert plan to items using shared utility @@ -19,7 +20,6 @@ export const planToProductV2 = ({ name: plan.name, is_add_on: plan.add_on, is_default: plan.default, - version: plan.version, group: plan.group ?? "", items, free_trial: plan.free_trial diff --git a/server/tests/customer-tests.md b/server/tests/customer-tests.md new file mode 100644 index 000000000..962a3b369 --- /dev/null +++ b/server/tests/customer-tests.md @@ -0,0 +1,29 @@ +get customer tests + +Important apiCusFeature fields: +- granted balance +- purchased balance +- current balance +- usage + + +1. Free metered feature -- test apiCusFeature fields () + - Tracking, should alter current balance + - Update customer entitlement (through `handleUpdateEntitlement.ts`), should alter current balance (maybe granted balance too? not sure yet), usage should stay the same + - + +2. Pay per use metered feature (with granted balance) + - Track a bit of the granted balance: purchased balance should be 0, granted balance should stay the same, current balance should be granted balance - usage, usage should be usage + - Track into overage: purchased balance should be overage amount, granted balance should stay the same, current balance should be 0 + - + +3. Prepaid features (with granted balance) + - Granted balance is what was passed into product config, purchased balance is how much was specified in prepaid + +4. Max purchase + +5. Rollovers + +6. Multiple feature balances (one off + metered) -- tests apiCusFeature (stuff should be added) and breakdown field + +7. Credit system? diff --git a/server/tests/customers/get-cus-feature1.test.ts b/server/tests/customers/get-cus-feature1.test.ts new file mode 100644 index 000000000..4f13a9119 --- /dev/null +++ b/server/tests/customers/get-cus-feature1.test.ts @@ -0,0 +1,76 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type ApiCusFeature, + ApiVersion, + type LimitedItem, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { timeout } from "../utils/genUtils.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}) as LimitedItem; + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "get-cus-feature1"; + +describe(`${chalk.yellowBright("get-cus-feature1: get customer, free metered feature")}`, () => { + const customerId = testCase; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("should track usage, and have correct customer feature fields", async () => { + const usage = 20.25; + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage, + }); + + await timeout(2000); + + const customer = await autumnV2.customers.get(customerId); + const feature = customer.features[ + TestFeature.Messages + ] as unknown as ApiCusFeature; + + expect(feature.granted_balance).toBe(messagesFeature.included_usage); + expect(feature.purchased_balance).toBe(0); + expect(feature.current_balance).toBe( + messagesFeature.included_usage - usage, + ); + expect(feature.usage).toBe(usage); + }); +}); diff --git a/shared/api/_prevVersions/openapi1.2.0.ts b/shared/api/_prevVersions/openapi1.2.0.ts index 4beb5b4eb..809947b51 100644 --- a/shared/api/_prevVersions/openapi1.2.0.ts +++ b/shared/api/_prevVersions/openapi1.2.0.ts @@ -11,23 +11,24 @@ import { SetUsageParamsSchema } from "../balances/usageModels.js"; import { CustomerDataSchema } from "../common/customerData.js"; import { EntityDataSchema } from "../common/entityData.js"; import { setUsageJsDoc } from "../common/jsDocs.js"; +import { ApiCusProductV3Schema } from "../customers/cusPlans/previousVersions/apiCusProductV3.js"; import { ApiEntityWithMeta } from "../entities/entitiesOpenApi.js"; import { ApiCusFeatureSchema, - ApiCusProductSchema, ApiCustomerSchema, ApiFeatureSchema, ApiProductItemSchema, + ApiProductSchema, CreateCustomerParamsSchema, CreateCustomerQuerySchema, CreateFeatureParamsSchema, FEATURE_EXAMPLE, GetCustomerQuerySchema, ListCustomersResponseSchema, + PRODUCT_EXAMPLE, UpdateCustomerParamsSchema, UpdateFeatureParamsSchema, } from "../models.js"; -import { ApiProductSchema, PRODUCT_EXAMPLE } from "../products/apiProduct.js"; import { CreateProductV2ParamsSchema, UpdateProductV2ParamsSchema, @@ -412,7 +413,7 @@ const OPENAPI_1_2_0 = createDocument( description: "Entity data for creating an entity", }), Customer: ApiCustomerWithMeta, - CustomerProduct: ApiCusProductSchema, + CustomerProduct: ApiCusProductV3Schema, CustomerFeature: ApiCusFeatureSchema.meta({ id: "CustomerFeature", description: "Customer feature object returned by the API", diff --git a/shared/api/customers/cusFeatures/apiCusFeature.ts b/shared/api/customers/cusFeatures/apiCusFeature.ts index 1ac9b2162..749e93fa0 100644 --- a/shared/api/customers/cusFeatures/apiCusFeature.ts +++ b/shared/api/customers/cusFeatures/apiCusFeature.ts @@ -30,10 +30,10 @@ export const ApiCusFeatureSchema = z.object({ unlimited: z.boolean(), - granted_balance: z.number(), // 0 - purchased_balance: z.number(), // 3 - current_balance: z.number(), // 1 - usage: z.number(), // 2 + granted_balance: z.number(), + purchased_balance: z.number(), + current_balance: z.number(), + usage: z.number(), resets_at: z.number().nullable(), diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index 39b138d8e..4df44ca92 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -9,10 +9,7 @@ import { nullish } from "@utils/utils.js"; import { Decimal } from "decimal.js"; import type { z } from "zod/v4"; import { ApiCusFeatureSchema } from "../apiCusFeature.js"; -import { - type ApiCusFeatureV3Breakdown, - ApiCusFeatureV3Schema, -} from "../previousVersions/apiCusFeatureV3.js"; +import { ApiCusFeatureV3Schema } from "../previousVersions/apiCusFeatureV3.js"; // overage_allowed: z.boolean().nullish().meta({ // description: "Whether overage usage beyond the limit is allowed", // example: true, @@ -45,43 +42,42 @@ export function transformCusFeatureToV3({ } else return type; }; - const itemInterval = - input.reset_interval === null - ? "multiple" - : resetIntvToEntIntv({ resetIntv: input.reset_interval }); + const itemInterval = nullish(input.reset?.interval) + ? "multiple" + : resetIntvToEntIntv({ resetIntv: input.reset?.interval }); - const usageLimit = toUsageLimit({ - maxPurchase: input.max_purchase, - startingBalance: input.starting_balance, - }); + // const usageLimit = toUsageLimit({ + // maxPurchase: input.max_purchase, + // startingBalance: input.starting_balance, + // }); - const overageAllowed = - Boolean(input.pay_per_use) && nullish(input.max_purchase); + // const overageAllowed = + // Boolean(input.pay_per_use) && nullish(input.max_purchase); - let newBreakdown: ApiCusFeatureV3Breakdown[] | undefined; - if (input.breakdown && input.breakdown.length > 0) { - newBreakdown = input.breakdown.map((breakdown) => { - const interval = resetIntvToEntIntv({ - resetIntv: breakdown.reset_interval, - }); + // let newBreakdown: ApiCusFeatureV3Breakdown[] | undefined; + // if (input.breakdown && input.breakdown.length > 0) { + // newBreakdown = input.breakdown.map((breakdown) => { + // const interval = resetIntvToEntIntv({ + // resetIntv: breakdown.reset_interval, + // }); - const usageLimit = toUsageLimit({ - maxPurchase: breakdown.max_purchase, - startingBalance: breakdown.starting_balance, - }); + // const usageLimit = toUsageLimit({ + // maxPurchase: breakdown.max_purchase, + // startingBalance: breakdown.starting_balance, + // }); - return { - interval, - interval_count: breakdown.reset_interval_count || 1, + // return { + // interval, + // interval_count: breakdown.reset_interval_count || 1, - balance: breakdown.balance, - usage: breakdown.usage, - included_usage: breakdown.starting_balance, - next_reset_at: breakdown.resets_at, - usage_limit: usageLimit, - } satisfies ApiCusFeatureV3Breakdown; - }); - } + // balance: breakdown.balance, + // usage: breakdown.usage, + // included_usage: breakdown.starting_balance, + // next_reset_at: breakdown.resets_at, + // usage_limit: usageLimit, + // } satisfies ApiCusFeatureV3Breakdown; + // }); + // } const v3Type = toV3Type({ type: input.feature?.type ?? ApiFeatureType.SingleUsage, @@ -96,8 +92,8 @@ export function transformCusFeatureToV3({ name: input.feature?.name ?? null, unlimited: input.unlimited, - included_usage: input.starting_balance, - balance: input.balance, + included_usage: input.granted_balance, + balance: input.current_balance, usage: input.usage, next_reset_at: input.resets_at, @@ -106,9 +102,9 @@ export function transformCusFeatureToV3({ ? undefined : itemInterval === "multiple" ? null - : input.reset_interval_count || 1, + : input.reset?.interval_count || 1, - overage_allowed: overageAllowed, + overage_allowed: input.pay_per_use, credit_schema: input.feature?.credit_schema ? input.feature.credit_schema.map((credit) => ({ @@ -117,8 +113,8 @@ export function transformCusFeatureToV3({ })) : undefined, - breakdown: newBreakdown, - usage_limit: usageLimit, + // breakdown: newBreakdown, + // usage_limit: usageLimit, rollovers: input.rollovers, }; } diff --git a/shared/api/products/planOpModels.ts b/shared/api/products/planOpModels.ts index 7a4e55c6f..2fdebf827 100644 --- a/shared/api/products/planOpModels.ts +++ b/shared/api/products/planOpModels.ts @@ -61,3 +61,5 @@ export const UpdatePlanQuerySchema = z.object({ upsert: z.boolean().optional(), disable_version: z.boolean().optional(), }); + +export type CreatePlanParams = z.infer; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts new file mode 100644 index 000000000..8605e76a3 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -0,0 +1,42 @@ +import { Decimal } from "decimal.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; +import { + cusEntToCusPrice, + entToOptions, +} from "../../productUtils/convertUtils.js"; +import { getBillingType } from "../../productUtils/priceUtils.js"; +import { nullish } from "../../utils.js"; + +export const cusEntToPurchasedBalance = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + // return 0; + // 1. If prepaid + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (nullish(cusPrice)) return 0; + + const billingType = getBillingType(cusPrice.price.config); + const billingUnits = cusPrice.price.config.billing_units || 1; + + if (billingType === BillingType.UsageInAdvance) { + // 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(); + + return quantityWithBillingUnits; + } + + // Return negative amount of balance... + return -(cusEnt.balance || 0); +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 6cc2bf3d6..fb52d055d 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -113,3 +113,37 @@ export const cusEntToMaxPurchase = ({ return 0; }; + +// NEW CUS ENT UTILS +export const cusEntToGrantedBalance = ({ + cusEnt, + entityId, + withRollovers = false, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; + withRollovers?: boolean; +}) => { + const rollover = getRolloverFields({ + cusEnt, + entityId, + }); + + const { count: entityCount } = getCusEntBalance({ + cusEnt, + entityId, + }); + + const grantedBalance = cusEnt.entitlement.allowance || 0; + + const total = new Decimal(grantedBalance).mul(entityCount).toNumber(); + + if (withRollovers && rollover) { + return new Decimal(total) + .add(rollover.balance) + .add(rollover.usage) + .toNumber(); + } + + return total; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index f0a0a3171..ce7d4176b 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,5 +1,6 @@ // Cus ent utils +export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; export * from "./cusEntUtils/balanceUtils.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; diff --git a/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts b/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts index 05c96403d..0126296f6 100644 --- a/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts +++ b/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts @@ -28,8 +28,9 @@ export const itemsToPlanFeatures = ({ feature_id: item.feature_id!, // Handle unlimited vs granted - granted: + granted_balance: item.included_usage === Infinite ? 0 : (item.included_usage ?? 0), + unlimited: item.included_usage === Infinite, // Convert intervals @@ -84,7 +85,7 @@ export const itemsToPlanFeatures = ({ : {}), // Other fields - reset_usage_on_enabled: item.reset_usage_when_enabled ?? true, + // reset_usage_on_enabled: item.reset_usage_when_enabled ?? true, // entity_feature_id: item.entity_feature_id, } satisfies ApiPlanFeature), ); diff --git a/shared/utils/planFeatureUtils/planFeaturesToItems.ts b/shared/utils/planFeatureUtils/planFeaturesToItems.ts index 0392dee1c..619569320 100644 --- a/shared/utils/planFeatureUtils/planFeaturesToItems.ts +++ b/shared/utils/planFeatureUtils/planFeaturesToItems.ts @@ -1,6 +1,5 @@ import type { ResetInterval } from "@api/models.js"; import type { ApiPlanFeature } from "@api/products/planFeature/apiPlanFeature.js"; -import { Infinite } from "@models/productModels/productEnums.js"; import { type ProductItem, ProductItemSchema, @@ -18,7 +17,7 @@ export const planFeaturesToItems = ({ (features ?? []).map((feature) => ProductItemSchema.parse({ feature_id: feature.feature_id, - included_usage: feature.unlimited ? Infinite : feature.granted, + // included_usage: feature.unlimited ? Infinite : feature.granted, interval: resetIntvToItemIntv(feature.reset_interval as ResetInterval), interval_count: feature.reset_interval_count, @@ -46,8 +45,8 @@ export const planFeaturesToItems = ({ usage_model: feature.price?.usage_model, billing_units: feature.price?.billing_units, usage_limit: feature.price?.max_purchase - ? feature.price.max_purchase + (feature.granted ?? 0) + ? feature.price.max_purchase + (feature.granted_balance ?? 0) : undefined, - reset_usage_when_enabled: feature.reset_usage_on_enabled, + reset_usage_when_enabled: feature.reset_usage_when_enabled, } satisfies ProductItem), ); diff --git a/shared/utils/planFeatureUtils/planToItems.ts b/shared/utils/planFeatureUtils/planToItems.ts index 17e4281d8..30da499d2 100644 --- a/shared/utils/planFeatureUtils/planToItems.ts +++ b/shared/utils/planFeatureUtils/planToItems.ts @@ -1,6 +1,7 @@ import type { ApiPlan } from "@api/products/apiPlan.js"; import type { BillingInterval } from "@models/productModels/priceModels/priceEnums.js"; import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels.js"; +import type { CreatePlanParams } from "../../api/products/planOpModels.js"; import { planFeaturesToItems } from "./planFeaturesToItems.js"; /** @@ -29,7 +30,7 @@ export const constructPriceItem = ({ export const convertPlanToItems = ({ plan, }: { - plan: ApiPlan; + plan: ApiPlan | CreatePlanParams; }): ProductItem[] => { // Convert features to items const featureItems = planFeaturesToItems({