From d4099508def5dbd5bc2328f4250c56bb67c9ee3f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 13 Feb 2026 15:50:07 +0000 Subject: [PATCH] feat: new customer object --- scripts/testGroups/all.sh | 12 +- .../handlers/handleProductsUpdated.ts | 5 +- .../api/check/checkTypes/CheckData.tsx | 5 +- .../check/checkUtils/apiBalanceToAllowed.ts | 34 +- .../api/check/checkUtils/getCheckData.ts | 29 +- .../check/checkUtils/getV2CheckResponse.ts | 6 +- .../src/internal/api/check/getCheckPreview.ts | 4 +- server/src/internal/api/check/handleCheck.ts | 8 +- .../internal/api/check/runCheckWithTrack.ts | 11 +- .../src/internal/balances/track/runTrackV2.ts | 9 +- .../track/utils/handleRedisTrackError.ts | 4 +- .../balances/track/utils/runPostgresTrack.ts | 4 +- .../balances/track/utils/runRedisTrack.ts | 4 +- .../deduction/deductionToTrackResponse.ts | 10 +- .../balances/utils/handleThresholdReached.ts | 6 +- .../sendProductsUpdated.ts | 2 +- .../checkForMisingBalance.ts | 22 +- .../getApiBalance/apiBalanceUtils.ts | 140 ++---- .../getApiBalance/cusProductToBalances.ts | 8 +- .../getApiBalance/getApiBalance.ts | 275 +++++------- .../getApiBalance/getApiBalances.ts | 17 +- .../cusUtils/apiCusUtils/getApiCustomer.ts | 16 +- .../apiCusUtils/getApiCustomerBase.ts | 27 +- .../apiCusUtils/getApiCustomerExpand.ts | 1 - .../getApiSubscription/getApiSubscription.ts | 5 +- .../getApiSubscription/getApiSubscriptions.ts | 45 +- .../apiEntityUtils/getApiEntity.ts | 18 +- .../apiEntityUtils/getApiEntityBase.ts | 24 +- .../check/prepaid/check-prepaid2.test.ts | 305 ------------- .../check/check-balance-price.test.ts | 107 +++++ .../balances/check/check-basic.test.ts | 98 ----- .../max-purchase/check-max-purchase.test.ts | 403 ++++++++++++++++++ .../track/track-across-all-versions.test.ts | 280 ++++++++++++ .../track/track-tinybird-migration.test.ts | 2 +- .../attach/new-plan/attach-one-time.test.ts | 15 +- .../legacy/attach/attach-edge-cases.test.ts | 4 +- .../billing/legacy/attach/attach-misc.test.ts | 2 +- .../cross-version-list-customers.test.ts | 54 +++ .../browserPool/completeInvoiceCheckout.ts | 2 +- server/tests/utils/fixtures/items.ts | 14 +- .../stripeUtils/completeInvoiceCheckout.ts | 2 +- .../tests/utils/testInitUtils/initScenario.ts | 16 + .../check/changes/V1.2_CheckChange.ts | 3 +- .../check/changes/V2.0_CheckChange.ts | 39 ++ shared/api/balances/check/checkLegacyData.ts | 2 - shared/api/balances/check/checkResponseV3.ts | 20 + .../track/changes/V2.0_TrackChange.ts | 56 +++ shared/api/balances/track/trackResponseV3.ts | 24 ++ shared/api/customers/apiCustomerV5.ts | 2 +- .../customers/changes/V1.2_CustomerChange.ts | 1 - .../customers/changes/V2.0_CustomerChange.ts | 19 +- .../changes/V1.2_CusFeatureChange.ts | 10 +- .../cusFeatures/cusFeatureLegacyData.ts | 14 - shared/api/customers/cusFeatures/index.ts | 4 +- .../cusFeatures/mappers/balanceV1ToV0.ts | 9 +- .../convert/apiBalanceV1ToAvailableOverage.ts | 55 +++ .../customers/cusPlans/apiSubscriptionV1.ts | 1 + shared/api/customers/cusPlans/index.ts | 8 + .../mappers/apiPurchaseV0ToSubscriptionV0.ts | 29 ++ .../mappers/apiSubscriptionV1ToPurchaseV0.ts | 1 + shared/api/customers/customerLegacyData.ts | 2 - .../V1.2_CustomerQueryChange.ts | 2 +- shared/api/entities/apiEntityV2.ts | 10 +- .../api/entities/changes/V1.2_EntityChange.ts | 1 - .../api/entities/changes/V2.0_EntityChange.ts | 26 +- shared/api/entities/entityLegacyData.ts | 2 - shared/api/entities/entityOpModels.ts | 2 +- .../requestChanges/V1.2_EntityQueryChange.ts | 2 +- shared/api/models.ts | 2 + .../versionChangeRegistry.ts | 10 +- shared/internal/checkout/checkoutResponses.ts | 4 +- shared/models/cusModels/cusExpand.ts | 2 +- .../balanceUtils/cusEntsToReset.ts | 7 +- .../customerEntitlementToBalancePrice.ts | 55 +++ .../rollovers/cusEntsToRolloverBalance.ts | 28 ++ .../rollovers/cusEntsToRolloverGranted.ts | 30 ++ .../rollovers/cusEntsToRolloverUsage.ts | 28 ++ shared/utils/cusEntUtils/index.ts | 7 +- shared/utils/expandUtils.ts | 2 +- 79 files changed, 1630 insertions(+), 944 deletions(-) delete mode 100644 server/tests/balances/check/prepaid/check-prepaid2.test.ts create mode 100644 server/tests/integration/balances/check/check-balance-price.test.ts create mode 100644 server/tests/integration/balances/check/max-purchase/check-max-purchase.test.ts create mode 100644 server/tests/integration/balances/track/track-across-all-versions.test.ts create mode 100644 server/tests/integration/customers/cross-version-list-customers.test.ts create mode 100644 shared/api/balances/check/changes/V2.0_CheckChange.ts create mode 100644 shared/api/balances/check/checkResponseV3.ts create mode 100644 shared/api/balances/track/changes/V2.0_TrackChange.ts create mode 100644 shared/api/balances/track/trackResponseV3.ts delete mode 100644 shared/api/customers/cusFeatures/cusFeatureLegacyData.ts create mode 100644 shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts create mode 100644 shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverBalance.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverGranted.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverUsage.ts diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index 624bd3e19..3eed10689 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -4,14 +4,14 @@ source "$(dirname "$0")/config.sh" export TEST_FILE_CONCURRENCY=2 BUN_PARALLEL_V2 \ - 'integration/billing/update-subscription' \ 'integration/billing/migrations' \ - # 'integration/billing/stripe-webhooks' \ - # 'integration/billing/autumn-webhooks' \ - # 'integration/billing/cron' \ - # 'integration/billing/attach' \ - # 'integration/crud/customers' \ + 'integration/crud/customers' \ + 'integration/billing/stripe-webhooks' \ + 'integration/billing/autumn-webhooks' \ + 'integration/billing/cron' \ # 'integration/crud/plans' \ + # 'integration/billing/update-subscription' \ + # 'integration/billing/attach' \ # 'integration/billing/attach' \ diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 53e251cf2..a7a8dbec2 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -139,7 +139,7 @@ export const handleProductsUpdated = async ({ add: [ CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan, - CusExpand.ScheduledSubscriptionsPlan, + CusExpand.PurchasesPlan, ], }); } @@ -147,6 +147,9 @@ export const handleProductsUpdated = async ({ const { apiCustomer, legacyData: cusLegacyData } = await getApiCustomerBase({ ctx, fullCus, + expandParams: { + plan: true, + }, }); const versionedCustomer = applyResponseVersionChanges< diff --git a/server/src/internal/api/check/checkTypes/CheckData.tsx b/server/src/internal/api/check/checkTypes/CheckData.tsx index 5d91b205a..ebcaf06b5 100644 --- a/server/src/internal/api/check/checkTypes/CheckData.tsx +++ b/server/src/internal/api/check/checkTypes/CheckData.tsx @@ -1,10 +1,9 @@ -import type { ApiBalance, CusFeatureLegacyData, Feature } from "@autumn/shared"; +import type { ApiBalanceV1, Feature } from "@autumn/shared"; export interface CheckData { customerId: string; entityId?: string; - apiBalance?: ApiBalance; + apiBalance?: ApiBalanceV1; originalFeature: Feature; featureToUse: Feature; - cusFeatureLegacyData?: CusFeatureLegacyData; } diff --git a/server/src/internal/api/check/checkUtils/apiBalanceToAllowed.ts b/server/src/internal/api/check/checkUtils/apiBalanceToAllowed.ts index 77b1a4bb4..278088e9b 100644 --- a/server/src/internal/api/check/checkUtils/apiBalanceToAllowed.ts +++ b/server/src/internal/api/check/checkUtils/apiBalanceToAllowed.ts @@ -1,4 +1,10 @@ -import { type ApiBalance, type Feature, FeatureType } from "@autumn/shared"; +import { + type ApiBalanceV1, + apiBalanceV1ToAvailableOverage, + type Feature, + FeatureType, + notNullish, +} from "@autumn/shared"; import { Decimal } from "decimal.js"; export const apiBalanceToAllowed = ({ @@ -6,7 +12,7 @@ export const apiBalanceToAllowed = ({ feature, requiredBalance, }: { - apiBalance: ApiBalance; + apiBalance: ApiBalanceV1; feature: Feature; requiredBalance: number; }) => { @@ -31,24 +37,20 @@ export const apiBalanceToAllowed = ({ // 3. Overage allowed if (apiBalance.overage_allowed) { - // No max purchase, allow overage - if (!apiBalance.max_purchase) { - return true; + // 1. Available overage + const availableOverage = apiBalanceV1ToAvailableOverage({ apiBalance }); + + if (notNullish(availableOverage)) { + return new Decimal(availableOverage) + .add(apiBalance.remaining) + .gte(requiredBalance); } - // Check if purchase_balance < max_purchase - const availableBalance = new Decimal(apiBalance.max_purchase) - .sub(apiBalance.purchased_balance) - .minus(apiBalance.current_balance); - - if (availableBalance.gte(requiredBalance)) { - return true; - } + return true; } - // 4. Balance >= required balance - - if (new Decimal(apiBalance.current_balance).gte(requiredBalance)) { + // 4. Balance >= required balance (V1 uses 'remaining' instead of 'current_balance') + if (new Decimal(apiBalance.remaining).gte(requiredBalance)) { return true; } diff --git a/server/src/internal/api/check/checkUtils/getCheckData.ts b/server/src/internal/api/check/checkUtils/getCheckData.ts index 5eb9e8088..75cc2677a 100644 --- a/server/src/internal/api/check/checkUtils/getCheckData.ts +++ b/server/src/internal/api/check/checkUtils/getCheckData.ts @@ -2,7 +2,6 @@ import { type ApiCustomerV5, type ApiEntityV2, type CheckParams, - type CustomerLegacyData, type Feature, FeatureNotFoundError, InternalError, @@ -105,7 +104,6 @@ export const getCheckData = async ({ } let apiEntity: ApiCustomerV5 | ApiEntityV2 | undefined; - let legacyData: CustomerLegacyData | undefined; const start = performance.now(); const fullCustomer = await getOrCreateCachedFullCustomer({ ctx, @@ -113,28 +111,24 @@ export const getCheckData = async ({ source: "getCheckData", }); - const { apiCustomer, legacyData: legacyDataResult } = - await getApiCustomerBase({ - ctx, - fullCus: fullCustomer, - withAutumnId: true, - }); + const { apiCustomer } = await getApiCustomerBase({ + ctx, + fullCus: fullCustomer, + withAutumnId: true, + }); ctx.logger.debug( `[check] getOrCreateCachedFullCustomer took ${performance.now() - start}ms`, ); apiEntity = apiCustomer; - legacyData = legacyDataResult; if (entity_id && fullCustomer.entity) { - const { apiEntity: apiEntityResult, legacyData: legacyDataResult } = - await getApiEntityBase({ - ctx, - entity: fullCustomer.entity, - fullCus: fullCustomer, - }); + const { apiEntity: apiEntityResult } = await getApiEntityBase({ + ctx, + entity: fullCustomer.entity, + fullCus: fullCustomer, + }); apiEntity = apiEntityResult; - legacyData = legacyDataResult; } if (!apiEntity) { @@ -151,8 +145,6 @@ export const getCheckData = async ({ }); const apiBalance = apiEntity.balances?.[featureToUse.id]; - const cusFeatureLegacyData = - legacyData?.cusFeatureLegacyData?.[featureToUse.id]; return { customerId: customer_id, @@ -160,6 +152,5 @@ export const getCheckData = async ({ apiBalance, originalFeature: feature, featureToUse, - cusFeatureLegacyData, }; }; diff --git a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts index 32343cd46..e64e48fbb 100644 --- a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts @@ -1,4 +1,4 @@ -import { CheckResponseV2Schema, FeatureType } from "@autumn/shared"; +import { CheckResponseV3Schema, FeatureType } from "@autumn/shared"; import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; import type { CheckData } from "../checkTypes/CheckData.js"; import { apiBalanceToAllowed } from "./apiBalanceToAllowed.js"; @@ -26,7 +26,7 @@ export const getV2CheckResponse = async ({ } if (!apiBalance) { - return CheckResponseV2Schema.parse({ + return CheckResponseV3Schema.parse({ allowed: false, customer_id: customerId || "", entity_id: entityId, @@ -41,7 +41,7 @@ export const getV2CheckResponse = async ({ requiredBalance, }); - return CheckResponseV2Schema.parse({ + return CheckResponseV3Schema.parse({ allowed, customer_id: customerId || "", entity_id: entityId, diff --git a/server/src/internal/api/check/getCheckPreview.ts b/server/src/internal/api/check/getCheckPreview.ts index a5c31967b..5f242d39e 100644 --- a/server/src/internal/api/check/getCheckPreview.ts +++ b/server/src/internal/api/check/getCheckPreview.ts @@ -1,5 +1,5 @@ import { - type CheckResponseV2, + type CheckResponseV3, cusProductToProduct, FeaturePreviewScenario, type FullCusProduct, @@ -29,7 +29,7 @@ export const getCheckPreview = async ({ entityId, }: { ctx: AutumnContext; - checkResponse: CheckResponseV2; + checkResponse: CheckResponseV3; checkData: CheckData; customerId: string; entityId?: string; diff --git a/server/src/internal/api/check/handleCheck.ts b/server/src/internal/api/check/handleCheck.ts index f8f9c57a2..398296d54 100644 --- a/server/src/internal/api/check/handleCheck.ts +++ b/server/src/internal/api/check/handleCheck.ts @@ -5,7 +5,7 @@ import { type CheckParams, CheckParamsSchema, CheckQuerySchema, - type CheckResponseV2, + type CheckResponseV3, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { getCheckData } from "./checkUtils/getCheckData.js"; @@ -54,7 +54,7 @@ export const handleCheck = createRoute({ requiredBalance, }); - let response: CheckResponseV2; + let response: CheckResponseV3; if (send_event) { response = await runCheckWithTrack({ ctx, @@ -79,14 +79,14 @@ export const handleCheck = createRoute({ }) : undefined; - const transformedResponse = applyResponseVersionChanges({ + // Version changes will transform V3 -> V2 -> V1 -> V0 based on target API version + const transformedResponse = applyResponseVersionChanges({ input: response, targetVersion: ctx.apiVersion, resource: AffectedResource.Check, legacyData: { noCusEnts: checkData.apiBalance === undefined, featureToUse: checkData.featureToUse, - cusFeatureLegacyData: checkData.cusFeatureLegacyData, }, ctx, }); diff --git a/server/src/internal/api/check/runCheckWithTrack.ts b/server/src/internal/api/check/runCheckWithTrack.ts index a0c550175..f745e6185 100644 --- a/server/src/internal/api/check/runCheckWithTrack.ts +++ b/server/src/internal/api/check/runCheckWithTrack.ts @@ -1,8 +1,8 @@ import { ApiVersion, type CheckParams, - type CheckResponseV2, - CheckResponseV2Schema, + type CheckResponseV3, + CheckResponseV3Schema, FeatureType, InsufficientBalanceError, InternalError, @@ -25,7 +25,7 @@ export const runCheckWithTrack = async ({ body: CheckParams; requiredBalance: number; checkData: CheckData; -}): Promise => { +}): Promise => { if (!body.feature_id) { throw new InternalError({ message: "ran check with track but no feature ID", @@ -58,11 +58,12 @@ export const runCheckWithTrack = async ({ let allowed = true; try { + // Use V2_1 to get ApiBalanceV1 format internally const response = await runTrackV2({ ctx, body: trackBody, featureDeductions, - apiVersion: ApiVersion.V2_0, + apiVersion: ApiVersion.V2_1, }); checkData.apiBalance = response.balance ?? undefined; @@ -86,7 +87,7 @@ export const runCheckWithTrack = async ({ }); } - const checkResponse = CheckResponseV2Schema.parse({ + const checkResponse = CheckResponseV3Schema.parse({ allowed, customer_id: checkData.customerId || "", entity_id: checkData.entityId, diff --git a/server/src/internal/balances/track/runTrackV2.ts b/server/src/internal/balances/track/runTrackV2.ts index 2eccaa150..2a484810f 100644 --- a/server/src/internal/balances/track/runTrackV2.ts +++ b/server/src/internal/balances/track/runTrackV2.ts @@ -6,7 +6,7 @@ import { ErrCode, RecaseError, type TrackParams, - type TrackResponseV2, + type TrackResponseV3, } from "@autumn/shared"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { getOrCreateCachedFullCustomer } from "../../customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; @@ -50,8 +50,8 @@ export const runTrackV2 = async ({ }); } - // Try Redis deduction - const response: TrackResponseV2 = await runRedisTrack({ + // Try Redis deduction - returns TrackResponseV3 (with ApiBalanceV1) + const response: TrackResponseV3 = await runRedisTrack({ ctx, fullCustomer, featureDeductions, @@ -59,7 +59,8 @@ export const runTrackV2 = async ({ body, }); - const transformedResponse = applyResponseVersionChanges({ + // Version changes will transform V3 -> V2 -> V1 -> V0 based on target API version + const transformedResponse = applyResponseVersionChanges({ input: response, targetVersion: apiVersion ? new ApiVersionClass(apiVersion) diff --git a/server/src/internal/balances/track/utils/handleRedisTrackError.ts b/server/src/internal/balances/track/utils/handleRedisTrackError.ts index e70ca8d17..5fa73cf14 100644 --- a/server/src/internal/balances/track/utils/handleRedisTrackError.ts +++ b/server/src/internal/balances/track/utils/handleRedisTrackError.ts @@ -1,7 +1,7 @@ import { InsufficientBalanceError, type TrackParams, - type TrackResponseV2, + type TrackResponseV3, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; @@ -26,7 +26,7 @@ export const handleRedisTrackError = async ({ error: Error; body: TrackParams; featureDeductions: FeatureDeduction[]; -}): Promise => { +}): Promise => { if (!(error instanceof RedisDeductionError)) { throw error; } diff --git a/server/src/internal/balances/track/utils/runPostgresTrack.ts b/server/src/internal/balances/track/utils/runPostgresTrack.ts index 48e4f3023..5eef29466 100644 --- a/server/src/internal/balances/track/utils/runPostgresTrack.ts +++ b/server/src/internal/balances/track/utils/runPostgresTrack.ts @@ -1,4 +1,4 @@ -import type { TrackParams, TrackResponseV2 } from "@autumn/shared"; +import type { TrackParams, TrackResponseV3 } from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js"; @@ -20,7 +20,7 @@ export const runPostgresTrack = async ({ ctx: AutumnContext; body: TrackParams; featureDeductions: FeatureDeduction[]; -}): Promise => { +}): Promise => { const fullCustomer = await getOrCreateCustomer({ ctx, customerId: body.customer_id, diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index 13f850fa6..077332c21 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -1,7 +1,7 @@ import type { FullCustomer, TrackParams, - TrackResponseV2, + TrackResponseV3, } from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; import { currentRegion } from "@/external/redis/initRedis.js"; @@ -85,7 +85,7 @@ export const runRedisTrack = async ({ featureDeductions: FeatureDeduction[]; overageBehavior: "cap" | "reject"; body: TrackParams; -}): Promise => { +}): Promise => { const { data: result, error } = await tryCatch( executeRedisDeduction({ ctx, diff --git a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts index bd4f89f56..1993f7c9c 100644 --- a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts +++ b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts @@ -1,4 +1,4 @@ -import type { ApiBalance, Feature, FullCustomer } from "@autumn/shared"; +import type { ApiBalanceV1, Feature, FullCustomer } from "@autumn/shared"; import { findCustomerEntitlementById, fullCustomerToCustomerEntitlements, @@ -11,8 +11,8 @@ import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; type TrackBalanceResponse = { - balance: ApiBalance | null; - balances?: Record; + balance: ApiBalanceV1 | null; + balances?: Record; }; /** @@ -151,8 +151,8 @@ export const deductionToTrackResponse = async ({ fullCus, }); - // 3. Build balances response - const finalBalances: Record = {}; + // 3. Build balances response (V1 format - version changes will transform for older API versions) + const finalBalances: Record = {}; // Add primary features (always - they were requested to be tracked) for (const deduction of featureDeductions) { diff --git a/server/src/internal/balances/utils/handleThresholdReached.ts b/server/src/internal/balances/utils/handleThresholdReached.ts index 3db831aba..e3db82689 100644 --- a/server/src/internal/balances/utils/handleThresholdReached.ts +++ b/server/src/internal/balances/utils/handleThresholdReached.ts @@ -117,7 +117,11 @@ export const handleThresholdReached = async ({ if (ctx.apiVersion.lte(ApiVersion.V1_2)) { ctx = addToExpand({ ctx, - add: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan], + add: [ + CusExpand.BalancesFeature, + CusExpand.SubscriptionsPlan, + CusExpand.PurchasesPlan, + ], }); } diff --git a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts index d90ea3458..5055bd7d6 100644 --- a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts +++ b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts @@ -83,7 +83,7 @@ export const sendProductsUpdated = async ({ add: [ CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan, - CusExpand.ScheduledSubscriptionsPlan, + CusExpand.PurchasesPlan, ], }); } diff --git a/server/src/internal/billing/v2/workflows/verifyCacheConsistency/checkForMisingBalance.ts b/server/src/internal/billing/v2/workflows/verifyCacheConsistency/checkForMisingBalance.ts index 1b3efef67..212334fed 100644 --- a/server/src/internal/billing/v2/workflows/verifyCacheConsistency/checkForMisingBalance.ts +++ b/server/src/internal/billing/v2/workflows/verifyCacheConsistency/checkForMisingBalance.ts @@ -64,25 +64,9 @@ export const checkForMisingBalance = async ({ const previousBalance = previousApiCustomer.balances?.[feature.id]; const newBalance = newApiCustomer.balances?.[feature.id]; - const previousGrantedBalance = previousBalance?.granted_balance ?? 0; - const newGrantedBalance = newBalance?.granted_balance ?? 0; - const previousPrepaidQuantity = - previousBalance?.breakdown?.reduce( - (acc, curr) => acc + curr.prepaid_quantity, - 0, - ) ?? 0; - const newPrepaidQuantity = - newBalance?.breakdown?.reduce( - (acc, curr) => acc + curr.prepaid_quantity, - 0, - ) ?? 0; - - const previousTotalBalance = new Decimal(previousGrantedBalance).plus( - previousPrepaidQuantity, - ); - const newTotalBalance = new Decimal(newGrantedBalance).plus( - newPrepaidQuantity, - ); + // In V1, 'granted' = included_grant + prepaid_grant (combined) + const previousTotalBalance = new Decimal(previousBalance?.granted ?? 0); + const newTotalBalance = new Decimal(newBalance?.granted ?? 0); const previousUsage = new Decimal(previousBalance?.usage ?? 0); const newUsage = new Decimal(newBalance?.usage ?? 0); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index b614f6ceb..ad7f77390 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -1,102 +1,19 @@ import { - type ApiBalance, - type ApiBalanceReset, - type ApiBalanceRollover, + type ApiBalanceBreakdownV1, + type ApiBalanceV1, type ApiFeatureV1, cusEntsToPlanId, - entIntvToResetIntv, - type Feature, + cusEntsToRollovers, type FullCusEntWithFullCusProduct, - getRolloverFields, - isContUseFeature, - notNullish, - toIntervalCountResponse, } from "@autumn/shared"; -const cusEntsToNextResetAt = ({ - cusEnts, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; -}) => { - const result = cusEnts.reduce((acc, curr) => { - if (curr.next_reset_at && curr.next_reset_at < acc) { - return curr.next_reset_at; - } - return acc; - }, Infinity); - - if (result === Infinity) return null; - - return result; -}; - -const cusEntsToReset = ({ - cusEnts, - feature, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - feature: Feature; -}): ApiBalanceReset | null => { - // 1. If feature is allocated, null - if (isContUseFeature({ feature })) return null; - - // Check if there are multiple intervals - const uniqueIntervals = [ - ...new Set(cusEnts.map((cusEnt) => cusEnt.entitlement.interval)), - ]; - - if (uniqueIntervals.length > 1) { - return { interval: "multiple", interval_count: undefined, resets_at: null }; - } - - // 3. Only 1 interval - return { - interval: entIntvToResetIntv({ - entInterval: cusEnts[0].entitlement.interval, - }), - - interval_count: toIntervalCountResponse({ - intervalCount: cusEnts[0].entitlement.interval_count, - }), - - resets_at: cusEntsToNextResetAt({ cusEnts }), - }; -}; - -const cusEntsToRollovers = ({ - cusEnts, - entityId, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - entityId?: string; -}): ApiBalanceRollover[] | undefined => { - // If all cus ents no rollover, return undefined - - if (cusEnts.every((cusEnt) => !cusEnt.entitlement.rollover)) { - return undefined; - } - - return cusEnts - .map((cusEnt) => { - const rolloverFields = getRolloverFields({ cusEnt, entityId }); - if (rolloverFields) - return rolloverFields.rollovers.map((rollover) => ({ - balance: rollover.balance, - expires_at: rollover.expires_at || 0, - })); - return []; - }) - .filter(notNullish) - .flat(); -}; - export const getBooleanApiBalance = ({ cusEnts, apiFeature, }: { cusEnts: FullCusEntWithFullCusProduct[]; apiFeature?: ApiFeatureV1; -}): ApiBalance => { +}): ApiBalanceV1 => { const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); const id = cusEnts[0].id; @@ -107,33 +24,30 @@ export const getBooleanApiBalance = ({ unlimited: false, - granted_balance: 0, - purchased_balance: 0, - current_balance: 0, + granted: 0, + remaining: 0, usage: 0, overage_allowed: false, max_purchase: null, - reset: null, + next_reset_at: null, - plan_id: planId, breakdown: [ { id, plan_id: planId, - granted_balance: 0, - purchased_balance: 0, - current_balance: 0, + included_grant: 0, + prepaid_grant: 0, + remaining: 0, usage: 0, - overage_allowed: false, - max_purchase: null, + unlimited: false, reset: null, - prepaid_quantity: 0, expires_at: null, - }, + price: null, + } satisfies ApiBalanceBreakdownV1, ], rollovers: undefined, - } satisfies ApiBalance; + } satisfies ApiBalanceV1; }; export const getUnlimitedApiBalance = ({ @@ -142,10 +56,11 @@ export const getUnlimitedApiBalance = ({ }: { apiFeature?: ApiFeatureV1; cusEnts: FullCusEntWithFullCusProduct[]; -}): ApiBalance => { +}): ApiBalanceV1 => { const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); const id = cusEnts[0].id; + const entityId = undefined; // Unlimited features don't have entity context return { feature: apiFeature, @@ -153,31 +68,28 @@ export const getUnlimitedApiBalance = ({ unlimited: true, - granted_balance: 0, - purchased_balance: 0, - current_balance: 0, + granted: 0, + remaining: 0, usage: 0, - reset: null, + next_reset_at: null, max_purchase: null, overage_allowed: false, - plan_id: planId, breakdown: [ { id, plan_id: planId, - granted_balance: 0, - purchased_balance: 0, - current_balance: 0, + included_grant: 0, + prepaid_grant: 0, + remaining: 0, usage: 0, - overage_allowed: false, - max_purchase: null, + unlimited: true, reset: null, - prepaid_quantity: 0, expires_at: null, - }, + price: null, + } satisfies ApiBalanceBreakdownV1, ], - rollovers: undefined, + rollovers: cusEntsToRollovers({ cusEnts, entityId }), }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts index ed176f5f0..aa8263916 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts @@ -1,5 +1,5 @@ import type { - ApiBalance, + ApiBalanceV1, FullCusEntWithFullCusProduct, FullCusProduct, FullCustomer, @@ -19,8 +19,8 @@ export const cusProductToBalances = ({ ctx: RequestContext; cusProduct: FullCusProduct; fullCustomer: FullCustomer; -}): Record => { - const balances: Record = {}; +}): Record => { + const balances: Record = {}; // Group customer_entitlements by feature_id const featureToCusEnts: Record = {}; @@ -56,8 +56,6 @@ export const cusProductToBalances = ({ fullCus: previewFullCus, cusEnts, feature, - includeRollovers: false, - includeBreakdown: false, }); balances[featureId] = data; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index cb7417356..bc4d3335f 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -1,117 +1,105 @@ import type { - ApiBalance, + ApiBalanceBreakdownV1, + ApiBalanceV1, FullCusEntWithFullCusProduct, FullCustomer, } from "@autumn/shared"; import { - type ApiBalanceBreakdown, - ApiBalanceBreakdownSchema, - ApiBalanceSchema, CheckExpand, CusExpand, cusEntsToAdjustment, cusEntsToAllowance, cusEntsToCurrentBalance, cusEntsToMaxPurchase, + cusEntsToNextResetAt, cusEntsToPlanId, cusEntsToPrepaidQuantity, - cusEntsToPurchasedBalance, cusEntsToReset, + cusEntsToRolloverBalance, + cusEntsToRolloverGranted, cusEntsToRollovers, - cusEntToKey, + cusEntsToRolloverUsage, + cusEntsToUsage, + customerEntitlementToBalancePrice, dbToApiFeatureV1, expandIncludes, type Feature, FeatureType, getCusEntBalance, + isUnlimitedCusEnt, nullish, sumValues, } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import type { CusFeatureLegacyData } from "../../../../../../../shared/api/customers/cusFeatures/cusFeatureLegacyData.js"; import { getBooleanApiBalance, getUnlimitedApiBalance, } from "./apiBalanceUtils.js"; -const cusEntsToBreakdown = ({ - ctx, +const getApiBalanceBreakdownItem = ({ fullCus, - cusEnts, + customerEntitlement, }: { - ctx: RequestContext; - cusEnts: FullCusEntWithFullCusProduct[]; fullCus: FullCustomer; -}): { - key: string; - breakdown: ApiBalanceBreakdown; - prepaidQuantity: number; -}[] => { + customerEntitlement: FullCusEntWithFullCusProduct; +}): ApiBalanceBreakdownV1 => { const entityId = fullCus.entity?.id; - const keyToCusEnts: Record = {}; - for (const cusEnt of cusEnts) { - const key = cusEntToKey({ cusEnt }); - keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt]; - } + const planId = cusEntsToPlanId({ cusEnts: [customerEntitlement] }); - const breakdown: { - key: string; - breakdown: ApiBalanceBreakdown; - prepaidQuantity: number; - }[] = []; + // Included grant + const allowance = cusEntsToAllowance({ + cusEnts: [customerEntitlement], + entityId, + }); + const adjustment = cusEntsToAdjustment({ + cusEnts: [customerEntitlement], + entityId, + }); + const includedGrant = new Decimal(allowance).add(adjustment).toNumber(); - for (const key in keyToCusEnts) { - const cusEnts = keyToCusEnts[key]; + // Prepaid grant + const prepaidGrant = cusEntsToPrepaidQuantity({ + cusEnts: [customerEntitlement], + sumAcrossEntities: nullish(entityId), + }); - const feature = cusEnts[0].entitlement.feature; - const reset = cusEntsToReset({ cusEnts, feature }); + // Remaining + const remaining = cusEntsToCurrentBalance({ + cusEnts: [customerEntitlement], + entityId, + }); - const { data: breakdownItem } = getApiBalance({ - ctx, - fullCus, - cusEnts, - feature, - includeRollovers: false, - includeBreakdown: false, - }); + // Usage + const usage = cusEntsToUsage({ cusEnts: [customerEntitlement], entityId }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ - cusEnts, - sumAcrossEntities: nullish(entityId), - }); + // Unlimited + const unlimited = isUnlimitedCusEnt(customerEntitlement); - const planId = cusEntsToPlanId({ cusEnts }); + // Reset + const reset = cusEntsToReset({ cusEnts: [customerEntitlement] }); - // Get expires_at from the first cusEnt (since key is cusEnt.id, there's only one) - const expiresAt = cusEnts[0]?.expires_at ?? null; + // Price + const price = customerEntitlementToBalancePrice({ customerEntitlement }); - breakdown.push({ - key, - breakdown: ApiBalanceBreakdownSchema.parse({ - id: key, + const expiresAt = customerEntitlement.expires_at; - plan_id: planId, - granted_balance: breakdownItem.granted_balance, - purchased_balance: breakdownItem.purchased_balance, - current_balance: breakdownItem.current_balance, - usage: breakdownItem.usage, + return { + id: customerEntitlement.id, + plan_id: planId, - max_purchase: breakdownItem.max_purchase, - overage_allowed: breakdownItem.overage_allowed, + included_grant: includedGrant, + prepaid_grant: prepaidGrant, + remaining: remaining, + usage: usage, + unlimited: unlimited, - reset: reset, - - prepaid_quantity: prepaidQuantity, - expires_at: expiresAt, - }), - prepaidQuantity: prepaidQuantity, - }); - } - - return breakdown; + reset: reset, + price: price, + expires_at: expiresAt, + }; }; export const getApiBalance = ({ @@ -119,16 +107,12 @@ export const getApiBalance = ({ fullCus, cusEnts, feature, - includeRollovers = true, - includeBreakdown = true, }: { ctx: RequestContext; fullCus: FullCustomer; cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; - includeRollovers?: boolean; - includeBreakdown?: boolean; -}): { data: ApiBalance; legacyData?: CusFeatureLegacyData } => { +}): { data: ApiBalanceV1 } => { const entityId = fullCus.entity?.id; const apiFeature = expandIncludes({ @@ -145,7 +129,6 @@ export const getApiBalance = ({ cusEnts, apiFeature, }), - legacyData: undefined, }; } @@ -159,7 +142,6 @@ export const getApiBalance = ({ if (unlimited) { return { data: getUnlimitedApiBalance({ apiFeature, cusEnts }), - legacyData: undefined, }; } @@ -170,108 +152,59 @@ export const getApiBalance = ({ }), ); + // Build breakdown items - one per customer entitlement + const breakdownItems = cusEnts.map((cusEnt) => + getApiBalanceBreakdownItem({ fullCus, customerEntitlement: cusEnt }), + ); + + // Calculate totals from breakdown + const totalGranted = sumValues( + breakdownItems.map((item) => + new Decimal(item.included_grant).add(item.prepaid_grant).toNumber(), + ), + ); + + const totalUsage = sumValues(breakdownItems.map((item) => item.usage)); + + const totalRemaining = sumValues( + breakdownItems.map((item) => item.remaining), + ); + const totalMaxPurchase = cusEntsToMaxPurchase({ cusEnts, entityId }); - const totalAllowanceWithRollovers = cusEntsToAllowance({ - cusEnts, - entityId, - withRollovers: includeRollovers, - }); + const nextResetAt = cusEntsToNextResetAt({ cusEnts }); - const totalAdjustment = cusEntsToAdjustment({ - cusEnts, - entityId, - }); - - const grantedBalance = new Decimal(totalAllowanceWithRollovers) - .add(totalAdjustment) - .toNumber(); - - // 2. Purchased balance - const totalPurchasedBalance = cusEntsToPurchasedBalance({ - cusEnts, - entityId, - }); - - // 3. Current balance - let currentBalance = cusEntsToCurrentBalance({ - cusEnts, - entityId, - withRollovers: includeRollovers, - }); - - currentBalance = new Decimal(currentBalance).add(totalUnused).toNumber(); - - // 4. Usage - const totalUsage = new Decimal(grantedBalance) - .add(totalPurchasedBalance) - .sub(currentBalance) - .toNumber(); - - const reset = cusEntsToReset({ cusEnts, feature }); - const rollovers = cusEntsToRollovers({ cusEnts, entityId }); - - const breakdown = includeBreakdown - ? cusEntsToBreakdown({ ctx, fullCus, cusEnts }) - : []; - - const planId = cusEntsToPlanId({ cusEnts }); - - const masterKey = breakdown ? null : cusEntToKey({ cusEnt: cusEnts[0] }); - - const { data: apiBalance, error } = ApiBalanceSchema.safeParse({ - feature: expandIncludes({ - expand: ctx.expand, - includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature], - }) - ? apiFeature - : undefined, - - feature_id: feature.id, - - unlimited: false, - - // Granted balance = granted balance + additional granted balance - granted_balance: grantedBalance, - - // Purchased balance = negative balance - purchased_balance: totalPurchasedBalance, - - // Current balance = balance + additional balance - current_balance: currentBalance, - - // Usage = granted balance + purchased balance - current balance - usage: totalUsage, - - // Max purchase... - overage_allowed: usageAllowed ?? false, - - max_purchase: totalMaxPurchase, - reset: reset, - - plan_id: planId, - breakdown: breakdown.map((item) => item.breakdown), - rollovers, - } satisfies ApiBalance); - - if (error) throw error; - - // Return in latest format - version transformation happens at Customer level - const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ - cusEnts, - sumAcrossEntities: nullish(entityId), - }); - const breakdownLegacyData = breakdown.map((item) => ({ - key: item.key, - prepaid_quantity: item.prepaidQuantity, - })); + // Rollover calculations + const totalRollovers = cusEntsToRollovers({ cusEnts, entityId }); + const totalRolloverGranted = cusEntsToRolloverGranted({ cusEnts, entityId }); + const totalRolloverBalance = cusEntsToRolloverBalance({ cusEnts, entityId }); + const totalRolloverUsage = cusEntsToRolloverUsage({ cusEnts, entityId }); return { - data: apiBalance, - legacyData: { - key: masterKey, - prepaid_quantity: totalPrepaidQuantity, - breakdown_legacy_data: breakdownLegacyData, + data: { + feature_id: feature.id, + feature: apiFeature, + + granted: new Decimal(totalGranted).add(totalRolloverGranted).toNumber(), + + remaining: new Decimal(totalRemaining) + .add(totalRolloverBalance) + .add(totalUnused) + .toNumber(), + + usage: new Decimal(totalUsage) + .add(totalRolloverUsage) + .sub(totalUnused) + .toNumber(), + + unlimited: unlimited, + overage_allowed: usageAllowed ?? false, + + max_purchase: totalMaxPurchase, + next_reset_at: nextResetAt, + + breakdown: breakdownItems, + rollovers: totalRollovers, }, }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index e76737246..012b5db63 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -1,6 +1,5 @@ import { - type ApiBalance, - type CusFeatureLegacyData, + type ApiBalanceV1, type FullCusEntWithFullCusProduct, type FullCustomer, fullCustomerToCustomerEntitlements, @@ -16,7 +15,7 @@ export const getApiBalances = async ({ }: { ctx: RequestContext; fullCus: FullCustomer; -}) => { +}): Promise<{ data: Record }> => { const { org } = ctx; const allCusEnts = fullCustomerToCustomerEntitlements({ @@ -34,26 +33,20 @@ export const getApiBalances = async ({ ]; } - const apiCusFeatures: Record = {}; - const cusFeaturesLegacyData: Record = {}; + const apiCusFeatures: Record = {}; for (const key in featureToCusEnt) { const feature = featureToCusEnt[key][0].entitlement.feature; const cusEnts = featureToCusEnt[key]; - // 1. Get cus feature for each breakdown - const { data, legacyData } = getApiBalance({ + const { data } = getApiBalance({ ctx, fullCus, cusEnts, feature, }); - // Otherwise... apiCusFeatures[feature.id] = data; - if (legacyData) { - cusFeaturesLegacyData[feature.id] = legacyData; - } } - return { data: apiCusFeatures, legacyData: cusFeaturesLegacyData }; + return { data: apiCusFeatures }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts index 6ae68c202..ded131f56 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts @@ -3,7 +3,6 @@ import { type ApiCustomerV5, applyResponseVersionChanges, CusExpand, - type CustomerLegacyData, type FullCustomer, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -23,12 +22,11 @@ export const getApiCustomer = async ({ withAutumnId?: boolean; }): Promise => { // Get base ApiCustomer (subscriptions, balances, invoices) - const { apiCustomer: baseCustomer, legacyData: customerLegacyData } = - await getApiCustomerBase({ - ctx, - fullCus: fullCustomer, - withAutumnId, - }); + const { apiCustomer: baseCustomer, legacyData } = await getApiCustomerBase({ + ctx, + fullCus: fullCustomer, + withAutumnId, + }); // Clean base customer (remove entities from base, handle expand) const cleanedBaseCustomer: ApiCustomerV5 = { @@ -53,11 +51,11 @@ export const getApiCustomer = async ({ }; // Apply version transformations based on API version - return applyResponseVersionChanges({ + return applyResponseVersionChanges({ input: apiCustomer, - legacyData: customerLegacyData, targetVersion: ctx.apiVersion, resource: AffectedResource.Customer, + legacyData, ctx, }); }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 9334d41b8..59ae462a6 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -20,22 +20,27 @@ export const getApiCustomerBase = async ({ ctx, fullCus, withAutumnId = true, + expandParams, }: { ctx: RequestContext; fullCus: FullCustomer; withAutumnId?: boolean; + expandParams?: { plan?: boolean }; }): Promise<{ apiCustomer: ApiCustomerV5; legacyData: CustomerLegacyData }> => { - const { data: apiBalances, legacyData: cusFeatureLegacyData } = - await getApiBalances({ - ctx, - fullCus, - }); + const { data: apiBalances } = await getApiBalances({ + ctx, + fullCus, + }); - const { data: apiSubscriptions, legacyData: cusProductLegacyData } = - await getApiSubscriptions({ - ctx, - fullCus, - }); + const { + subscriptions: apiSubscriptions, + purchases: apiPurchases, + legacyData: cusProductLegacyData, + } = await getApiSubscriptions({ + ctx, + fullCus, + expandParams, + }); const apiCustomer = ApiCustomerV5Schema.extend({ autumn_id: z.string().optional(), @@ -53,6 +58,7 @@ export const getApiCustomerBase = async ({ metadata: fullCus.metadata ?? {}, subscriptions: apiSubscriptions, + purchases: apiPurchases, balances: apiBalances, send_email_receipts: fullCus.send_email_receipts ?? false, @@ -68,7 +74,6 @@ export const getApiCustomerBase = async ({ apiCustomer, legacyData: { cusProductLegacyData, - cusFeatureLegacyData, }, }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 41fa316db..f09149dc0 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -29,7 +29,6 @@ export const getApiCustomerExpand = async ({ filter: [ CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan, - CusExpand.ScheduledSubscriptionsPlan, CusExpand.Invoices, ], }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index c5a0431a8..a9c807098 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -10,6 +10,7 @@ import { expandIncludes, type FullCusProduct, type FullCustomer, + isCustomerProductOneOff, isCustomerProductTrialing, type Subscription, } from "@autumn/shared"; @@ -81,10 +82,10 @@ export const getApiSubscription = async < // Use expandParams.plan if provided, otherwise fall back to ctx.expand const shouldExpandPlan = expandParams?.plan ?? - (status === "scheduled" + (isCustomerProductOneOff(cusProduct) ? expandIncludes({ expand: ctx.expand, - includes: [CusExpand.ScheduledSubscriptionsPlan], + includes: [CusExpand.PurchasesPlan], }) : expandIncludes({ expand: ctx.expand, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts index f5ba875c3..f48b9b21f 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts @@ -1,23 +1,28 @@ import { ACTIVE_STATUSES, type ApiSubscriptionV1, + apiSubscription, type CusProductLegacyData, type CusProductStatus, type FullCustomer, + isCustomerProductOneOff, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { getApiSubscription } from "./getApiSubscription.js"; +const getSubscriptionStatusKey = (cp: ApiSubscriptionV1) => { + if (!("status" in cp)) return undefined; + if (ACTIVE_STATUSES.includes(cp.status as CusProductStatus)) return "active"; + return cp.status; +}; + const mergeSubscriptionsResponses = ({ subscriptions, }: { subscriptions: ApiSubscriptionV1[]; }) => { const getPlanKey = (cp: ApiSubscriptionV1) => { - const status = ACTIVE_STATUSES.includes(cp.status as CusProductStatus) - ? "active" - : cp.status; - return `${cp.plan_id}:${status}`; + return `${cp.plan_id}:${getSubscriptionStatusKey(cp)}`; }; const record: Record = {}; @@ -28,15 +33,16 @@ const mergeSubscriptionsResponses = ({ const currStartedAt = curr.started_at; + const curCanceledAt = "canceled_at" in curr ? curr.canceled_at : null; + const curQuantity = "quantity" in curr ? curr.quantity : 0; + record[key] = { ...(latest || curr), - canceled_at: curr.canceled_at - ? curr.canceled_at - : latest?.canceled_at || null, + canceled_at: curCanceledAt ? curCanceledAt : latest?.canceled_at || null, started_at: latest?.started_at ? Math.min(latest?.started_at, currStartedAt) : currStartedAt, - quantity: (latest?.quantity || 0) + (curr?.quantity || 0), + quantity: (latest?.quantity || 0) + curQuantity, }; } @@ -46,13 +52,15 @@ const mergeSubscriptionsResponses = ({ export const getApiSubscriptions = async ({ ctx, fullCus, + expandParams, }: { ctx: RequestContext; fullCus: FullCustomer; + expandParams?: { plan?: boolean }; }) => { // Process full subscriptions const apiSubs: ApiSubscriptionV1[] = []; - // const apiPurchases: ApiPurchaseV0[] = []; + const apiPurchasesAsSubscriptions: ApiSubscriptionV1[] = []; const cusProducts = fullCus.customer_products; @@ -64,7 +72,11 @@ export const getApiSubscriptions = async ({ fullCus, }); - apiSubs.push(processed.data); + if (isCustomerProductOneOff(cusProduct)) { + apiPurchasesAsSubscriptions.push(processed.data); + } else { + apiSubs.push(processed.data); + } legacyData[processed.data.plan_id] = processed.legacyData; } @@ -72,8 +84,19 @@ export const getApiSubscriptions = async ({ subscriptions: apiSubs, }); + const mergedPurchasesAsSubscriptions = mergeSubscriptionsResponses({ + subscriptions: apiPurchasesAsSubscriptions, + }); + + const mergedPurchases = mergedPurchasesAsSubscriptions.map((sub) => + apiSubscription.map.v1ToPurchaseV0({ + apiSubscriptionV1: sub, + }), + ); + return { - data: merged, + subscriptions: merged, + purchases: mergedPurchases, legacyData, }; }; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts index 0d0a374f4..1946e4369 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts @@ -2,7 +2,6 @@ import { AffectedResource, type ApiEntityV2, applyResponseVersionChanges, - type EntityLegacyData, EntityNotFoundError, type FullCustomer, } from "@autumn/shared"; @@ -41,13 +40,12 @@ export const getApiEntity = async ({ } // Get base entity (cacheable or direct from DB) - const { apiEntity: baseEntity, legacyData: entityLegacyData } = - await getApiEntityBase({ - ctx, - entity: fullCustomer.entity, - fullCus: fullCustomer, - withAutumnId, - }); + const { apiEntity: baseEntity, legacyData } = await getApiEntityBase({ + ctx, + entity: fullCustomer.entity, + fullCus: fullCustomer, + withAutumnId, + }); // Clean api entity const cleanedEntity = { @@ -69,11 +67,11 @@ export const getApiEntity = async ({ ...apiEntityExpand, }; - return applyResponseVersionChanges({ + return applyResponseVersionChanges({ input: apiEntity, - legacyData: entityLegacyData, targetVersion: ctx.apiVersion, resource: AffectedResource.Entity, + legacyData, ctx, }); }; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index d94013bf8..9f4e03dff 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -43,17 +43,19 @@ export const getApiEntityBase = async ({ }; // Reuse existing customer functions with filtered products - const { data: apiBalances, legacyData: cusFeatureLegacyData } = - await getApiBalances({ - ctx, - fullCus: filteredFullCus, - }); + const { data: apiBalances } = await getApiBalances({ + ctx, + fullCus: filteredFullCus, + }); - const { data: apiSubscriptions, legacyData: cusProductLegacyData } = - await getApiSubscriptions({ - ctx, - fullCus: filteredFullCus, - }); + const { + subscriptions: apiSubscriptions, + purchases: apiPurchases, + legacyData: cusProductLegacyData, + } = await getApiSubscriptions({ + ctx, + fullCus: filteredFullCus, + }); const apiEntity = ApiEntityV2Schema.extend({ autumn_id: z.string().optional(), @@ -67,6 +69,7 @@ export const getApiEntityBase = async ({ env: fullCus.env, subscriptions: apiSubscriptions, + purchases: apiPurchases, balances: apiBalances, } satisfies ApiEntityV2); @@ -74,7 +77,6 @@ export const getApiEntityBase = async ({ apiEntity, legacyData: { cusProductLegacyData, - cusFeatureLegacyData, }, }; }; diff --git a/server/tests/balances/check/prepaid/check-prepaid2.test.ts b/server/tests/balances/check/prepaid/check-prepaid2.test.ts deleted file mode 100644 index a41bc99ee..000000000 --- a/server/tests/balances/check/prepaid/check-prepaid2.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - type CheckResponseV1, - type CheckResponseV2, - type LimitedItem, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructArrearItem, - constructPrepaidItem, -} 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"; - -const prepaidItem = constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - billingUnits: 100, - price: 8.5, -}) as LimitedItem; - -const usageItem = constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - price: 0.5, - billingUnits: 1, - usageLimit: 500, -}) as LimitedItem; - -const prod = constructProduct({ - type: "free", - isDefault: false, - items: [prepaidItem, usageItem], -}); - -const testCase = "check-prepaid2"; - -describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per use feature")}`, () => { - const customerId = testCase; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const prepaidQuantity = 500; - const grantedBalance = prepaidItem.included_usage + usageItem.included_usage; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [prod], - prefix: testCase, - }); - - await autumnV1.attach({ - customer_id: customerId, - product_id: prod.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: prepaidQuantity, - }, - ], - }); - }); - - test("should have correct v2 response for empty usage", async () => { - const res = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(res).toMatchObject({ - allowed: true, - customer_id: customerId, - required_balance: 1, - balance: { - feature_id: TestFeature.Messages, - unlimited: false, - granted_balance: grantedBalance, - purchased_balance: prepaidQuantity, - current_balance: prepaidQuantity + grantedBalance, - usage: 0, - max_purchase: null, - overage_allowed: true, - reset: { - interval: prepaidItem.interval, - }, - }, - }); - - expect(res.balance?.reset?.resets_at).toBeDefined(); - - const expectedPrepaidBreakdown = { - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: prepaidQuantity + prepaidItem.included_usage, - usage: 0, - max_purchase: null, - overage_allowed: false, - reset: expect.objectContaining({ - interval: "month", - }), - }; - - const expectedUsageBreakdown = { - granted_balance: usageItem.included_usage, - purchased_balance: 0, - current_balance: usageItem.included_usage, - usage: 0, - max_purchase: 300, - overage_allowed: true, - reset: expect.objectContaining({ - interval: "month", - }), - }; - - expect(res.balance?.breakdown).toHaveLength(2); - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining(expectedPrepaidBreakdown), - ); - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining(expectedUsageBreakdown), - ); - }); - - test("should have correct v1 response for empty usage", async () => { - const res = (await autumnV1.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV1; - - expect(res).toMatchObject({ - allowed: true, - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: 1, - interval: prepaidItem.interval, - unlimited: false, - included_usage: prepaidQuantity + grantedBalance, - balance: prepaidQuantity + grantedBalance, - usage: 0, - }); - }); - - let curUsage = 0; - test("should track 500 and verify check response uses prepaid balance first", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 500, - }); - - const res = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - const expectedCurrentBalance = prepaidQuantity + grantedBalance - 500; - curUsage = 500; - - const balance = res.balance; - - expect(balance?.granted_balance).toBe(grantedBalance); - expect(balance?.current_balance).toBe(expectedCurrentBalance); - expect(balance?.usage).toBe(curUsage); - expect(balance?.purchased_balance).toBe(500); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: - prepaidQuantity + prepaidItem.included_usage - curUsage, - usage: curUsage, - overage_allowed: false, - }), - ); - }); - - // Balances at this point: - test("should track another 500 -- 100 from prepaid, 200 from usage-based granted, 200 paid", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 500, - }); - - const res = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - curUsage = curUsage + 500; - - const balance = res.balance; - - expect(balance).toMatchObject({ - granted_balance: grantedBalance, - current_balance: 0, - usage: curUsage, - purchased_balance: prepaidQuantity + 200, - }); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: 0, - usage: 600, - overage_allowed: false, - }), - ); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: usageItem.included_usage, - purchased_balance: 200, - current_balance: 0, - usage: 400, - overage_allowed: true, - }), - ); - }); - - test("should track another 200 and only 100 used due to usage limit", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 200, - }); - - const res = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - curUsage = curUsage + 100; - - const balance = res.balance; - expect(balance).toMatchObject({ - usage: curUsage, - purchased_balance: prepaidQuantity + 300, - }); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: usageItem.included_usage, - purchased_balance: 300, - current_balance: 0, - usage: 500, - max_purchase: 300, - overage_allowed: true, - }), - ); - }); - - test("should check that non-cached customer returns correct response", async () => { - await timeout(4000); - const res = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - skip_cache: true, - })) as unknown as CheckResponseV2; - - expect(res.balance).toMatchObject({ - granted_balance: grantedBalance, - current_balance: 0, - usage: curUsage, - purchased_balance: prepaidQuantity + 300, - }); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: 0, - usage: 600, - overage_allowed: false, - }), - ); - - expect(res.balance?.breakdown).toContainEqual( - expect.objectContaining({ - granted_balance: usageItem.included_usage, - purchased_balance: 300, - current_balance: 0, - usage: 500, - max_purchase: 300, - overage_allowed: true, - }), - ); - }); -}); diff --git a/server/tests/integration/balances/check/check-balance-price.test.ts b/server/tests/integration/balances/check/check-balance-price.test.ts new file mode 100644 index 000000000..4dfae0cd9 --- /dev/null +++ b/server/tests/integration/balances/check/check-balance-price.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { + BillingMethod, + type CheckResponseV3, + type ProductItem, + ProductItemInterval, + UsageModel, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Balance price field verification +// Tests that balance.breakdown[].price returns correct structure: +// - Single tier: returns { amount } instead of { tiers } +// - Multiple tiers: returns { tiers } array +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-balance-price: verify price field in balance breakdown")}`, async () => { + // Messages: single price (no tiers) - should return amount + const messagesItem: ProductItem = { + feature_id: TestFeature.Messages, + usage_model: UsageModel.PayPerUse, + included_usage: 100, + price: 0.5, + billing_units: 1, + interval: ProductItemInterval.Month, + }; + + // Words: multiple tiers - should return tiers array + const wordsItem: ProductItem = { + feature_id: TestFeature.Words, + usage_model: UsageModel.PayPerUse, + included_usage: 50, + tiers: [ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.05 }, + { to: "inf", amount: 0.02 }, + ], + billing_units: 1, + interval: ProductItemInterval.Month, + }; + + const proProd = products.pro({ + id: "price-test", + items: [messagesItem, wordsItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "check-balance-price", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [s.attach({ productId: proProd.id })], + }); + + // ───────────────────────────────────────────────────────────────── + // Check messages (single price) - should have amount, no tiers + // ───────────────────────────────────────────────────────────────── + + const messagesRes = (await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV3; + + expect(messagesRes.allowed).toBe(true); + expect(messagesRes.balance).toBeDefined(); + expect(messagesRes.balance?.breakdown).toHaveLength(1); + + const messagesBreakdown = messagesRes.balance?.breakdown?.[0]; + expect(messagesBreakdown?.price).toMatchObject({ + amount: 0.5, + billing_units: 1, + billing_method: BillingMethod.UsageBased, + max_purchase: null, + }); + expect(messagesBreakdown?.price?.tiers).toBeUndefined(); + + // ───────────────────────────────────────────────────────────────── + // Check words (multiple tiers) - should have tiers, no amount + // ───────────────────────────────────────────────────────────────── + + const wordsRes = (await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Words, + })) as unknown as CheckResponseV3; + + expect(wordsRes.allowed).toBe(true); + expect(wordsRes.balance).toBeDefined(); + expect(wordsRes.balance?.breakdown).toHaveLength(1); + + const wordsBreakdown = wordsRes.balance?.breakdown?.[0]; + expect(wordsBreakdown?.price).toMatchObject({ + billing_units: 1, + billing_method: BillingMethod.UsageBased, + max_purchase: null, + }); + expect(wordsBreakdown?.price?.amount).toBeUndefined(); + expect(wordsBreakdown?.price?.tiers).toEqual([ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.05 }, + { to: "inf", amount: 0.02 }, + ]); +}); diff --git a/server/tests/integration/balances/check/check-basic.test.ts b/server/tests/integration/balances/check/check-basic.test.ts index 302a627e0..f78caf1a0 100644 --- a/server/tests/integration/balances/check/check-basic.test.ts +++ b/server/tests/integration/balances/check/check-basic.test.ts @@ -636,101 +636,3 @@ test.concurrent(`${chalk.yellowBright("check-multiple-balances: /check on featur usage_allowed: true, }); }); - -// ═══════════════════════════════════════════════════════════════════ -// CHECK: Feature with usage limits -// ═══════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("check-usage-limits: /check on feature with usage limits")}`, async () => { - const messagesFeature = constructArrearItem({ - featureId: TestFeature.Messages, - price: 0.5, - includedUsage: 100, - usageLimit: 500, - }) as LimitedItem; - - const proProd = products.pro({ - id: "pro", - items: [messagesFeature], - }); - - const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); - - const { customerId, autumnV1, autumnV2 } = await initScenario({ - customerId: "check-usage-limits", - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [proProd] }), - ], - actions: [s.attach({ productId: proProd.id })], - }); - - // v2 response - const resV2 = (await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: messagesFeature.usage_limit! + 1, - })) as unknown as CheckResponseV2; - - expect(resV2).toMatchObject({ - allowed: false, - customer_id: customerId, - required_balance: messagesFeature.usage_limit! + 1, - balance: { - feature_id: "messages", - unlimited: false, - granted_balance: messagesFeature.included_usage, - purchased_balance: 0, - current_balance: messagesFeature.included_usage, - usage: 0, - max_purchase: - messagesFeature.usage_limit! - messagesFeature.included_usage, - overage_allowed: true, - reset: { - interval: "month", - }, - }, - }); - - // v1 response - const resV1 = (await autumnV1.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: messagesFeature.usage_limit! + 1, - })) as unknown as CheckResponseV1; - - const expectedResV1 = { - allowed: false, - customer_id: customerId, - balance: messagesFeature.included_usage, - feature_id: TestFeature.Messages as string, - required_balance: messagesFeature.usage_limit! + 1, - code: SuccessCode.FeatureFound, - unlimited: false, - usage: 0, - included_usage: messagesFeature.included_usage, - overage_allowed: false, - usage_limit: messagesFeature.usage_limit!, - interval: "month", - interval_count: 1, - }; - - expect(resV1).toMatchObject(expectedResV1); - expect(resV1.next_reset_at).toBeDefined(); - - // v0 response - const resV0 = (await autumnV0.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - required_balance: messagesFeature.usage_limit! + 1, - })) as unknown as CheckResponseV0; - - expect(resV0.allowed).toBe(false); - expect(resV0.balances).toBeDefined(); - expect(resV0.balances).toHaveLength(1); - expect(resV0.balances[0]).toMatchObject({ - balance: messagesFeature.included_usage, - required: messagesFeature.usage_limit! + 1, - feature_id: TestFeature.Messages, - }); -}); diff --git a/server/tests/integration/balances/check/max-purchase/check-max-purchase.test.ts b/server/tests/integration/balances/check/max-purchase/check-max-purchase.test.ts new file mode 100644 index 000000000..19ac597db --- /dev/null +++ b/server/tests/integration/balances/check/max-purchase/check-max-purchase.test.ts @@ -0,0 +1,403 @@ +import { expect, test } from "bun:test"; +import { + type CheckResponseV0, + type CheckResponseV1, + type CheckResponseV2, + SuccessCode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Feature with usage limits (initial state, no tracking) +// Migrated from: check-basic.test.ts / check-usage-limits +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-max-purchase-initial: /check on feature with usage limits")}`, async () => { + const maxPurchase = 400; + const messagesFeature = items.consumableMessages({ + includedUsage: 100, + maxPurchase, + price: 0.5, + }); + + const proProd = products.pro({ + id: "pro", + items: [messagesFeature], + }); + + const { customerId, autumnV0, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-max-purchase-initial", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [s.attach({ productId: proProd.id })], + }); + + const usageLimit = maxPurchase + messagesFeature.included_usage; + + // ───────────────────────────────────────────────────────────────── + // Check with required_balance = usageLimit (should be allowed) + // ───────────────────────────────────────────────────────────────── + + const resV2Allowed = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: usageLimit, + })) as unknown as CheckResponseV2; + + expect(resV2Allowed.allowed).toBe(true); + + // ───────────────────────────────────────────────────────────────── + // Check with required_balance > usageLimit (should NOT be allowed) + // ───────────────────────────────────────────────────────────────── + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: usageLimit + 1, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: false, + customer_id: customerId, + required_balance: usageLimit + 1, + balance: { + feature_id: "messages", + unlimited: false, + granted_balance: messagesFeature.included_usage, + purchased_balance: 0, + current_balance: messagesFeature.included_usage, + usage: 0, + max_purchase: maxPurchase, + overage_allowed: true, + reset: { + interval: "month", + }, + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: usageLimit + 1, + })) as unknown as CheckResponseV1; + + expect(resV1).toMatchObject({ + allowed: false, + customer_id: customerId, + balance: messagesFeature.included_usage, + feature_id: TestFeature.Messages as string, + required_balance: usageLimit + 1, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: messagesFeature.included_usage, + overage_allowed: false, + usage_limit: usageLimit, + interval: "month", + interval_count: 1, + }); + expect(resV1.next_reset_at).toBeDefined(); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: usageLimit + 1, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(false); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(1); + expect(resV0.balances[0]).toMatchObject({ + balance: messagesFeature.included_usage, + required: usageLimit + 1, + feature_id: TestFeature.Messages, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Prepaid + pay-per-use feature with usage limits (with tracking) +// Migrated from: server/tests/balances/check/prepaid/check-prepaid2.test.ts +// +// Setup: +// - Prepaid item: includedUsage=100, billingUnits=100, price=8.5 +// - Usage item: includedUsage=200, price=0.5, maxPurchase=300 +// - Attach with prepaidQuantity=500 +// +// Total granted_balance = 100 + 200 = 300 +// Total initial balance = 300 (granted) + 500 (prepaid) = 800 +// +// Tracking sequence: +// 1. Track 500 → all goes to prepaid first +// 2. Track 500 → 100 from prepaid, 200 from usage granted, 200 paid overage +// 3. Track 200 → only 100 used due to usage limit cap +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-max-purchase-prepaid-consumable: /check on prepaid + pay-per-use with usage limits")}`, async () => { + const prepaidItem = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8.5, + }); + + const maxPurchase = 300; + const usageItem = items.consumableMessages({ + includedUsage: 200, + price: 0.5, + maxPurchase, + }); + + const prod = products.base({ + id: "prepaid-consumable", + items: [prepaidItem, usageItem], + }); + + const prepaidQuantity = 500; + const grantedBalance = prepaidItem.included_usage + usageItem.included_usage; // 300 + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-max-purchase-prepaid-consumable", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [prod] }), + ], + actions: [ + s.attach({ + productId: prod.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + // ───────────────────────────────────────────────────────────────── + // Initial state (no usage) + // ───────────────────────────────────────────────────────────────── + + const resV2Initial = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resV2Initial).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + feature_id: TestFeature.Messages, + unlimited: false, + granted_balance: grantedBalance, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + grantedBalance, + usage: 0, + max_purchase: null, // Aggregated is null since prepaid has no limit + overage_allowed: true, + reset: { + interval: prepaidItem.interval, + }, + }, + }); + expect(resV2Initial.balance?.reset?.resets_at).toBeDefined(); + + // Verify breakdowns + expect(resV2Initial.balance?.breakdown).toHaveLength(2); + expect(resV2Initial.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + prepaidItem.included_usage, + usage: 0, + max_purchase: null, + overage_allowed: false, + }), + ); + expect(resV2Initial.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 0, + current_balance: usageItem.included_usage, + usage: 0, + max_purchase: maxPurchase, + overage_allowed: true, + }), + ); + + // V1 response for initial state + const resV1Initial = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + expect(resV1Initial).toMatchObject({ + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 1, + interval: prepaidItem.interval, + unlimited: false, + included_usage: prepaidQuantity + grantedBalance, + balance: prepaidQuantity + grantedBalance, + usage: 0, + }); + + // ───────────────────────────────────────────────────────────────── + // Track 600: All usage goes to prepaid balance first + // ───────────────────────────────────────────────────────────────── + + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 600, + }); + + const resAfterTrack600 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfterTrack600.balance).toMatchObject({ + granted_balance: grantedBalance, + current_balance: 200, + usage: 600, + purchased_balance: 500, + }); + + expect(resAfterTrack600.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + overage_allowed: false, + }), + ); + + // ───────────────────────────────────────────────────────────────── + // Track another 400: 200 from usage granted, 200 paid + // ───────────────────────────────────────────────────────────────── + + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 400, + }); + + const resAfterTrack1000 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfterTrack1000.balance).toMatchObject({ + granted_balance: grantedBalance, + current_balance: 0, + usage: 1000, + purchased_balance: prepaidQuantity + 200, + }); + + expect(resAfterTrack1000.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + overage_allowed: false, + }), + ); + + expect(resAfterTrack1000.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 200, + current_balance: 0, + usage: 400, + max_purchase: maxPurchase, + overage_allowed: true, + }), + ); + + // ───────────────────────────────────────────────────────────────── + // Track another 200: Only 100 actually used due to usage limit + // Usage-based has max_purchase=300, already purchased 200, so only 100 more + // ───────────────────────────────────────────────────────────────── + + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + const resAfterTrack1200 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfterTrack1200.balance).toMatchObject({ + usage: 1100, // Only 100 actually used due to cap + purchased_balance: prepaidQuantity + 300, + }); + + expect(resAfterTrack1200.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: maxPurchase, + overage_allowed: true, + }), + ); + + // ───────────────────────────────────────────────────────────────── + // Verify skip_cache returns correct response (tests postgres sync) + // ───────────────────────────────────────────────────────────────── + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + const resSkipCache = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + })) as unknown as CheckResponseV2; + + // Note: not asserting `allowed` here - the skip_cache test is primarily + // verifying that postgres sync returns correct balance data + expect(resSkipCache.balance).toMatchObject({ + granted_balance: grantedBalance, + current_balance: 0, + usage: 1100, + purchased_balance: prepaidQuantity + 300, + }); + + expect(resSkipCache.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + overage_allowed: false, + }), + ); + + expect(resSkipCache.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: maxPurchase, + overage_allowed: true, + }), + ); +}); diff --git a/server/tests/integration/balances/track/track-across-all-versions.test.ts b/server/tests/integration/balances/track/track-across-all-versions.test.ts new file mode 100644 index 000000000..47c587460 --- /dev/null +++ b/server/tests/integration/balances/track/track-across-all-versions.test.ts @@ -0,0 +1,280 @@ +/** + * Track per version: + * + * - V1.2: + * - Included Usage is the free usage and any prepaid quantity + * - Overage is negative balance + * - Usage is the total usage + * - Balance is included_usage + * + * - V2.0: + * - Granted Balance is only the free usage + * - Purchased Balance is the prepaid quantity + the negative portion of balance + * - Current Balance is the granted balance + the purchased balance - the usage EXCLUDING the overage + * - Usage is the total usage EXCLUDING the overage + * + * - V2.1: + * - Granted is only the free usage and any prepaid quantity + * - Usage is the total usage + * - Remaining is the current balance + */ + +import { expect, test } from "bun:test"; +import type { + ApiBalance, + ApiBalanceBreakdown, + ApiCusFeatureV3, + ApiCusFeatureV3Breakdown, + ApiCustomer, +} from "@autumn/shared"; +import type { ApiCustomerV5 } from "@shared/api/customers/apiCustomerV5"; +import type { + ApiBalanceBreakdownV1, + ApiBalanceV1, +} from "@shared/api/customers/cusFeatures/apiBalanceV1"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const testCase = "track-across-all-versions"; + +const prepaidCreditsItem = items.prepaid({ + featureId: TestFeature.Credits, + price: 1, + billingUnits: 1000, + includedUsage: 5000, +}); +const consumableCreditsItem = items.consumable({ + featureId: TestFeature.Credits, + includedUsage: 0, + price: 1, + billingUnits: 1000, +}); + +const pro = products.pro({ + id: "pro", + items: [prepaidCreditsItem, consumableCreditsItem], +}); + +test.concurrent(`${chalk.yellowBright("track-across-all-versions: track across all versions")}`, async () => { + const { customerId, autumnV1, autumnV2, autumnV2_1 } = await initScenario({ + customerId: testCase, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Credits, quantity: 5000 }], + }), + ], + }); + + const refreshCustomerBalances = async () => { + const v1_2customer = await autumnV1.customers.get(customerId); + const v2customer = await autumnV2.customers.get(customerId); + const v2_1customer = await autumnV2_1.customers.get(customerId); + + const v1_2balance = v1_2customer.features[TestFeature.Credits]; + const v2balance = (v2customer as unknown as ApiCustomer).balances[ + TestFeature.Credits + ]; + const v2_1balance = (v2_1customer as unknown as ApiCustomerV5).balances[ + TestFeature.Credits + ]; + + const v1_2breakdown = v1_2balance.breakdown ?? []; + const v2breakdown = v2balance.breakdown ?? []; + const v2_1breakdown = v2_1balance.breakdown ?? []; + + return { + v1_2balance, + v2balance, + v2_1balance, + v1_2breakdown, + v2breakdown, + v2_1breakdown, + }; + }; + + let { v1_2balance, v2balance, v2_1balance } = await refreshCustomerBalances(); + + // Initial state: 5000 included + 5000 prepaid = 10000 total + expect(v1_2balance).toMatchObject({ + included_usage: 10_000, + usage: 0, + balance: 10_000, + overage_allowed: true, + } satisfies Partial); + + expect(v2balance).toMatchObject({ + granted_balance: 5000, + current_balance: 10_000, + usage: 0, + purchased_balance: 5000, + overage_allowed: true, + } satisfies Partial); + + expect(v2_1balance).toMatchObject({ + granted: 10_000, + usage: 0, + remaining: 10_000, + overage_allowed: true, + } satisfies Partial); + + // Track 1000 usage + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 1000, + }); + + ({ v1_2balance, v2balance, v2_1balance } = await refreshCustomerBalances()); + + expect(v1_2balance).toMatchObject({ + included_usage: 10_000, + usage: 1000, + balance: 9_000, + overage_allowed: true, + } satisfies Partial); + + expect(v2balance).toMatchObject({ + granted_balance: 5000, + current_balance: 9_000, + usage: 1000, + purchased_balance: 5000, + overage_allowed: true, + } satisfies Partial); + + expect(v2_1balance).toMatchObject({ + granted: 10_000, + usage: 1000, + remaining: 9_000, + overage_allowed: true, + } satisfies Partial); + + // Track 5000 more usage (6000 total) + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 5_000, + }); + + ({ v1_2balance, v2balance, v2_1balance } = await refreshCustomerBalances()); + + expect(v1_2balance).toMatchObject({ + included_usage: 10_000, + usage: 6_000, + balance: 4_000, + overage_allowed: true, + } satisfies Partial); + + expect(v2balance).toMatchObject({ + granted_balance: 5000, + current_balance: 4_000, + usage: 6_000, + purchased_balance: 5000, + overage_allowed: true, + } satisfies Partial); + + expect(v2_1balance).toMatchObject({ + granted: 10_000, + usage: 6_000, + remaining: 4_000, + overage_allowed: true, + } satisfies Partial); + + // Track 5000 more (11000 total - goes into overage) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 5_000, + }); + + const { v1_2breakdown, v2breakdown, v2_1breakdown } = + await refreshCustomerBalances(); + ({ v1_2balance, v2balance, v2_1balance } = await refreshCustomerBalances()); + + expect(v1_2balance).toMatchObject({ + included_usage: 10_000, + usage: 11_000, + balance: -1_000, + overage_allowed: true, + } satisfies Partial); + + expect(v2balance).toMatchObject({ + granted_balance: 5000, + current_balance: 0, + usage: 11_000, + purchased_balance: 6000, + overage_allowed: true, + } satisfies Partial); + + expect(v2_1balance).toMatchObject({ + granted: 10_000, + usage: 11_000, + remaining: 0, + overage_allowed: true, + } satisfies Partial); + + // Verify breakdowns + expect(v1_2breakdown ?? []).toHaveLength(2); + expect(v2breakdown ?? []).toHaveLength(2); + expect(v2_1breakdown ?? []).toHaveLength(2); + + expect(v1_2breakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + included_usage: 10_000, + usage: 10_000, + balance: 0, + overage_allowed: false, + } satisfies Partial), + expect.objectContaining({ + included_usage: 0, + usage: 1000, + balance: -1000, + overage_allowed: true, + } satisfies Partial), + ]) satisfies Partial, + ); + + expect(v2breakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + granted_balance: 5_000, + current_balance: 0, + usage: 10_000, + purchased_balance: 5_000, + overage_allowed: false, + } satisfies Partial), + expect.objectContaining({ + granted_balance: 0, + current_balance: 0, + usage: 1000, + purchased_balance: 1_000, + overage_allowed: true, + } satisfies Partial), + ]) satisfies Partial, + ); + + expect(v2_1breakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + included_grant: 5_000, + prepaid_grant: 5_000, + usage: 10_000, + remaining: 0, + } satisfies Partial), + expect.objectContaining({ + included_grant: 0, + prepaid_grant: 0, + usage: 1000, + remaining: 0, + } satisfies Partial), + ]) satisfies Partial, + ); +}); diff --git a/server/tests/integration/balances/track/track-tinybird-migration.test.ts b/server/tests/integration/balances/track/track-tinybird-migration.test.ts index c6907dd04..9c9f0c99d 100644 --- a/server/tests/integration/balances/track/track-tinybird-migration.test.ts +++ b/server/tests/integration/balances/track/track-tinybird-migration.test.ts @@ -90,7 +90,7 @@ test("tinybird migration - dual write", async () => { ); // Wait for async Tinybird ingestion - await timeout(5000); + await timeout(10000); // Verify all events in Tinybird const checkEvent = await eventActions.getEventById({ diff --git a/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts b/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts index b95a3885e..a8ce88a95 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts @@ -171,6 +171,11 @@ test.concurrent(`${chalk.yellowBright("new-plan: onetime-cumulative")}`, async ( const customer = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer, + active: [oneOff.id], + }); + // Verify messages balance is cumulative (200 = 100 + 100) expectCustomerFeatureCorrect({ customer, @@ -245,7 +250,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: onetime-leaves-pro")}`, async ( const customer = await autumnV1.customers.get(customerId); - expectCustomerProducts({ + await expectCustomerProducts({ customer, active: [pro.id, oneOff.id], }); @@ -402,13 +407,9 @@ test.concurrent(`${chalk.yellowBright("new-plan: onetime-addon")}`, async () => const customer = await autumnV1.customers.get(customerId); // Verify both products are active - await expectProductActive({ + await expectCustomerProducts({ customer, - productId: pro.id, - }); - await expectProductActive({ - customer, - productId: oneOffAddon.id, + active: [pro.id, oneOffAddon.id], }); // Verify combined messages balance (100 from pro + 50 from one-off = 150) diff --git a/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts b/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts index e21040b38..f79f53fe8 100644 --- a/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-edge-cases.test.ts @@ -35,7 +35,7 @@ test.concurrent(`${chalk.yellowBright("attach: alipay payment method returns che expect(res.checkout_url).toContain("checkout.stripe.com"); }); -test.concurrent(`${chalk.yellowBright("attach: pro then switch to alipay, add premium addon")}`, async () => { +test.concurrent(`${chalk.yellowBright("attach: pro then upgrade to premium with alipay")}`, async () => { const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const pro = products.pro({ @@ -50,7 +50,7 @@ test.concurrent(`${chalk.yellowBright("attach: pro then switch to alipay, add pr }); const { customerId, autumnV1, ctx, customer } = await initScenario({ - customerId: "alipay-addon-2", + customerId: "alipay-upgrade", setup: [ s.customer({ withDefault: false, paymentMethod: "success" }), s.products({ list: [pro, premium] }), diff --git a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts index d8791955d..3963255dc 100644 --- a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts @@ -109,7 +109,7 @@ test.concurrent(`${chalk.yellowBright("attach-misc: convert collection method fr await stripeCli.invoices.pay(invoiceStripeId); // Wait for webhook processing - await timeout(5000); + await timeout(10000); const cusProduct = await getMainCusProduct({ db, diff --git a/server/tests/integration/customers/cross-version-list-customers.test.ts b/server/tests/integration/customers/cross-version-list-customers.test.ts new file mode 100644 index 000000000..a697b17b8 --- /dev/null +++ b/server/tests/integration/customers/cross-version-list-customers.test.ts @@ -0,0 +1,54 @@ +import { test } from "bun:test"; +import { + type ApiCustomer, + ApiCustomerSchema, + type ApiCustomerV3, + ApiCustomerV3Schema, + ApiVersion, +} from "@autumn/shared"; +import { + type ApiCustomerV5, + ApiCustomerV5Schema, +} from "@shared/api/customers/apiCustomerV5"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +test.concurrent(`${chalk.yellowBright("cross-version-list-customers: list customers cross version")}`, async () => { + const { autumnV1 } = await initScenario({ + customerId: "cross-version-list-customers", + setup: [s.customer({})], + actions: [], + }); + + const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 }); + const autumnV2_0 = new AutumnInt({ version: ApiVersion.V2_0 }); + + // V2.1 - should return ApiCustomerV5 schema (V1 balances with granted/remaining) + const customersV2_1 = (await autumnV2_1.customers.list()) as { + list: ApiCustomerV5[]; + }; + for (const customer of customersV2_1.list) { + ApiCustomerV5Schema.parse(customer); + } + + // V2.0 - should return ApiCustomer schema (V0 balances with granted_balance/current_balance) + const customersV2_0 = (await autumnV2_0.customers.list()) as { + list: ApiCustomer[]; + }; + for (const customer of customersV2_0.list) { + ApiCustomerSchema.parse(customer); + } + + // V1.2 - should return ApiCustomerV3 schema (features format) + const customersV1_2 = (await autumnV1.customers.list()) as { + list: ApiCustomerV3[]; + }; + for (const customer of customersV1_2.list) { + ApiCustomerV3Schema.parse(customer); + } + + console.log( + "Listed customersV2_1, customersV2_0, and customersV1_2 successfully", + ); +}); diff --git a/server/tests/utils/browserPool/completeInvoiceCheckout.ts b/server/tests/utils/browserPool/completeInvoiceCheckout.ts index 6adf95469..7c1d969b7 100644 --- a/server/tests/utils/browserPool/completeInvoiceCheckout.ts +++ b/server/tests/utils/browserPool/completeInvoiceCheckout.ts @@ -130,7 +130,7 @@ export const completeInvoiceCheckout = async ({ ); if (postalInput) { await postalInput.click(); - await postalInput.type("94107"); + await postalInput.type("SW59SX"); } } catch (error) { console.log("Could not find postal code input:", error); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 9b9f56026..38ef788f6 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -446,6 +446,7 @@ const tieredOneOffMessages = ({ * @param billingUnits - Units per price (default: 1) * @param entityFeatureId - Entity feature ID for per-entity balances * @param interval - Billing interval (default: month) + * @param maxPurchase - Maximum overage allowed (usage_limit = maxPurchase + includedUsage) */ const consumable = ({ featureId, @@ -454,6 +455,7 @@ const consumable = ({ billingUnits = 1, entityFeatureId, interval = ProductItemInterval.Month, + maxPurchase, }: { featureId: string; includedUsage?: number; @@ -461,6 +463,7 @@ const consumable = ({ billingUnits?: number; entityFeatureId?: string; interval?: ProductItemInterval; + maxPurchase?: number; }): LimitedItem => constructArrearItem({ featureId, @@ -469,6 +472,8 @@ const consumable = ({ billingUnits, entityFeatureId, interval, + usageLimit: + maxPurchase !== undefined ? maxPurchase + includedUsage : undefined, }) as LimitedItem; /** @@ -476,23 +481,30 @@ const consumable = ({ * @param includedUsage - Free units before overage kicks in (default: 0) * @param entityFeatureId - Entity feature ID for per-entity balances * @param interval - Billing interval (default: month) + * @param maxPurchase - Maximum overage allowed (usage_limit = maxPurchase + includedUsage) + * @param price - Price per unit (default: 0.1) */ const consumableMessages = ({ includedUsage = 0, entityFeatureId, interval = ProductItemInterval.Month, + maxPurchase, + price = 0.1, }: { includedUsage?: number; entityFeatureId?: string; interval?: ProductItemInterval; + maxPurchase?: number; + price?: number; } = {}): LimitedItem => consumable({ featureId: TestFeature.Messages, includedUsage, - price: 0.1, + price, billingUnits: 1, entityFeatureId, interval, + maxPurchase, }); /** diff --git a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts index 465ca5135..acbf4d1d2 100644 --- a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts +++ b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts @@ -141,7 +141,7 @@ export const completeInvoiceCheckout = async ({ ); if (postalInput) { await postalInput.click(); - await postalInput.type("94107"); + await postalInput.type("SW59SX"); } } catch (error) { console.log("Could not find postal code input:", error); diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index c7c3e6dc5..6486840af 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -896,9 +896,11 @@ export async function initScenario(params: { ctx?: TestContext; }): Promise<{ customerId: string; + autumnV0: AutumnInt; autumnV1: AutumnInt; autumnV1Beta: AutumnInt; autumnV2: AutumnInt; + autumnV2_1: AutumnInt; testClockId: string | undefined; customer: Awaited>["customer"]; ctx: TestContext; @@ -917,9 +919,11 @@ export async function initScenario(params: { ctx?: TestContext; }): Promise<{ customerId: undefined; + autumnV0: AutumnInt; autumnV1: AutumnInt; autumnV1Beta: AutumnInt; autumnV2: AutumnInt; + autumnV2_1: AutumnInt; testClockId: undefined; customer: null; ctx: TestContext; @@ -1074,6 +1078,11 @@ export async function initScenario({ } // 3. Create autumn clients + const autumnV0 = new AutumnInt({ + version: ApiVersion.V0_2, + secretKey: ctx.orgSecretKey, + }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2, secretKey: ctx.orgSecretKey, @@ -1089,6 +1098,11 @@ export async function initScenario({ secretKey: ctx.orgSecretKey, }); + const autumnV2_1 = new AutumnInt({ + version: ApiVersion.V2_1, + secretKey: ctx.orgSecretKey, + }); + // 4. Create entities if any (requires customerId) if (generatedEntities.length > 0) { if (!customerId) { @@ -1414,9 +1428,11 @@ export async function initScenario({ return { customerId, + autumnV0, autumnV1, autumnV1Beta, autumnV2, + autumnV2_1, testClockId, customer, ctx, diff --git a/shared/api/balances/check/changes/V1.2_CheckChange.ts b/shared/api/balances/check/changes/V1.2_CheckChange.ts index c3bcaa3e1..a8232a714 100644 --- a/shared/api/balances/check/changes/V1.2_CheckChange.ts +++ b/shared/api/balances/check/changes/V1.2_CheckChange.ts @@ -44,7 +44,7 @@ export const V1_2_CheckChange = defineVersionChange({ throw new Error("Legacy data is required"); } - const { cusFeatureLegacyData, featureToUse } = legacyData; + const { featureToUse } = legacyData; if (!input.balance) { return { @@ -59,7 +59,6 @@ export const V1_2_CheckChange = defineVersionChange({ const cusFeatureV3 = transformBalanceToCusFeatureV3({ input: input.balance, - legacyData: cusFeatureLegacyData, }); const baseData = { diff --git a/shared/api/balances/check/changes/V2.0_CheckChange.ts b/shared/api/balances/check/changes/V2.0_CheckChange.ts new file mode 100644 index 000000000..837b9daed --- /dev/null +++ b/shared/api/balances/check/changes/V2.0_CheckChange.ts @@ -0,0 +1,39 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { balanceV1ToV0 } from "../../../customers/cusFeatures/mappers/balanceV1ToV0.js"; +import { CheckResponseV2Schema } from "../checkResponseV2.js"; +import { CheckResponseV3Schema } from "../checkResponseV3.js"; + +/** + * V2_0_CheckChange: Transforms check response from V3 (V2.1) to V2 (V2.0) format + * + * Applied when: targetVersion <= V2.0 + * + * Changes: + * - Transforms balance from ApiBalanceV1 to ApiBalance (V0 format) + */ +export const V2_0_CheckChange = defineVersionChange({ + name: "V2_0 Check Change", + newVersion: ApiVersion.V2_1, + oldVersion: ApiVersion.V2_0, + description: ["Balance schema transform (V1 to V0)"], + affectedResources: [AffectedResource.Check], + newSchema: CheckResponseV3Schema, + oldSchema: CheckResponseV2Schema, + affectsResponse: true, + + transformResponse: ({ + input, + }: { + input: z.infer; + }): z.infer => { + return { + ...input, + balance: input.balance ? balanceV1ToV0({ input: input.balance }) : null, + }; + }, +}); diff --git a/shared/api/balances/check/checkLegacyData.ts b/shared/api/balances/check/checkLegacyData.ts index d0aab8673..d865adb40 100644 --- a/shared/api/balances/check/checkLegacyData.ts +++ b/shared/api/balances/check/checkLegacyData.ts @@ -1,11 +1,9 @@ import { z } from "zod/v4"; import { FeatureSchema } from "../../../models/featureModels/featureModels.js"; -import { CusFeatureLegacyDataSchema } from "../../models.js"; export const CheckLegacyDataSchema = z.object({ noCusEnts: z.boolean(), featureToUse: FeatureSchema, - cusFeatureLegacyData: CusFeatureLegacyDataSchema, }); export type CheckLegacyData = z.infer; diff --git a/shared/api/balances/check/checkResponseV3.ts b/shared/api/balances/check/checkResponseV3.ts new file mode 100644 index 000000000..58f07bebc --- /dev/null +++ b/shared/api/balances/check/checkResponseV3.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; +import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; +import { CheckFeaturePreviewSchema } from "./checkFeaturePreview.js"; + +/** + * Check response V3 - uses ApiBalanceV1 (V2.1 format) + * This is the server's internal response format + */ +export const CheckResponseV3Schema = z.object({ + allowed: z.boolean(), + customer_id: z.string(), + entity_id: z.string().nullish(), + required_balance: z.number().optional(), + + balance: ApiBalanceV1Schema.nullable(), + + preview: CheckFeaturePreviewSchema.optional(), +}); + +export type CheckResponseV3 = z.infer; diff --git a/shared/api/balances/track/changes/V2.0_TrackChange.ts b/shared/api/balances/track/changes/V2.0_TrackChange.ts new file mode 100644 index 000000000..d619f189f --- /dev/null +++ b/shared/api/balances/track/changes/V2.0_TrackChange.ts @@ -0,0 +1,56 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import type { ApiBalance } from "../../../customers/cusFeatures/apiBalance.js"; +import { balanceV1ToV0 } from "../../../customers/cusFeatures/mappers/balanceV1ToV0.js"; +import { TrackResponseV2Schema } from "../trackResponseV2.js"; +import { TrackResponseV3Schema } from "../trackResponseV3.js"; + +/** + * V2_0_TrackChange: Transforms track response from V3 (V2.1) to V2 (V2.0) format + * + * Applied when: targetVersion <= V2.0 + * + * Changes: + * - Transforms balance from ApiBalanceV1 to ApiBalance (V0 format) + * - Transforms balances record from ApiBalanceV1 to ApiBalance (V0 format) + */ +export const V2_0_TrackChange = defineVersionChange({ + name: "V2_0 Track Change", + newVersion: ApiVersion.V2_1, + oldVersion: ApiVersion.V2_0, + description: ["Balance schema transform (V1 to V0)"], + affectedResources: [AffectedResource.Track], + newSchema: TrackResponseV3Schema, + oldSchema: TrackResponseV2Schema, + affectsResponse: true, + + transformResponse: ({ + input, + }: { + input: z.infer; + }): z.infer => { + // Transform single balance + const transformedBalance = input.balance + ? balanceV1ToV0({ input: input.balance }) + : null; + + // Transform balances record + let transformedBalances: Record | undefined; + if (input.balances) { + transformedBalances = {}; + for (const [featureId, balance] of Object.entries(input.balances)) { + transformedBalances[featureId] = balanceV1ToV0({ input: balance }); + } + } + + return { + ...input, + balance: transformedBalance, + balances: transformedBalances, + }; + }, +}); diff --git a/shared/api/balances/track/trackResponseV3.ts b/shared/api/balances/track/trackResponseV3.ts new file mode 100644 index 000000000..aff112cbc --- /dev/null +++ b/shared/api/balances/track/trackResponseV3.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; +import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; + +/** + * Track response V3 - uses ApiBalanceV1 (V2.1 format) + * This is the server's internal response format + */ +export const TrackResponseV3Schema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + }), + entity_id: z.string().optional().meta({ + description: "The ID of the entity (if provided)", + }), + event_name: z.string().optional().meta({ + description: "The name of the event", + }), + + value: z.number(), + balance: ApiBalanceV1Schema.nullable(), + balances: z.record(z.string(), ApiBalanceV1Schema).optional(), +}); + +export type TrackResponseV3 = z.infer; diff --git a/shared/api/customers/apiCustomerV5.ts b/shared/api/customers/apiCustomerV5.ts index c624c35f3..47f169077 100644 --- a/shared/api/customers/apiCustomerV5.ts +++ b/shared/api/customers/apiCustomerV5.ts @@ -10,7 +10,7 @@ import { // V5 base customer - uses V1 subscriptions (single array with status field) and V1 balances export const BaseApiCustomerV5Schema = BaseApiCustomerSchema.extend({ subscriptions: z.array(ApiSubscriptionV1Schema), - purchases: z.record(z.string(), ApiPurchaseV0Schema), + purchases: z.array(ApiPurchaseV0Schema), balances: z.record(z.string(), ApiBalanceV1Schema), }); diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index b4710d6ce..972ccd583 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -85,7 +85,6 @@ export const V1_2_CustomerChange = defineVersionChange({ for (const [featureId, feature] of Object.entries(input.balances)) { v3_features[featureId] = transformBalanceToCusFeatureV3({ input: feature, - legacyData: legacyData?.cusFeatureLegacyData[featureId], }); } diff --git a/shared/api/customers/changes/V2.0_CustomerChange.ts b/shared/api/customers/changes/V2.0_CustomerChange.ts index 60abc2a39..5c41b1bf1 100644 --- a/shared/api/customers/changes/V2.0_CustomerChange.ts +++ b/shared/api/customers/changes/V2.0_CustomerChange.ts @@ -9,8 +9,8 @@ import { ApiCustomerV5Schema } from "../apiCustomerV5.js"; import type { ApiBalance } from "../cusFeatures/apiBalance.js"; import { balanceV1ToV0 } from "../cusFeatures/mappers/balanceV1ToV0.js"; import type { ApiSubscription } from "../cusPlans/apiSubscription.js"; +import { apiPurchaseV0ToSubscriptionV0 } from "../cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.js"; import { apiSubscriptionV1ToV0 } from "../cusPlans/mappers/apiSubscriptionV1ToV0.js"; -import { CustomerLegacyDataSchema } from "../customerLegacyData.js"; export const V2_0_CustomerChange = defineVersionChange({ name: "V2_0 Customer Change", @@ -20,26 +20,18 @@ export const V2_0_CustomerChange = defineVersionChange({ affectedResources: [AffectedResource.Customer], newSchema: ApiCustomerV5Schema, oldSchema: ApiCustomerSchema, - legacyDataSchema: CustomerLegacyDataSchema, affectsResponse: true, transformResponse: ({ input, - legacyData, }: { input: z.infer; - legacyData?: z.infer; }): z.infer => { // Transform balances from V1 to V0 const transformedBalances: Record = {}; if (input.balances) { for (const [featureId, balance] of Object.entries(input.balances)) { - // Get per-feature legacy data for this balance - const featureLegacyData = legacyData?.cusFeatureLegacyData?.[featureId]; - transformedBalances[featureId] = balanceV1ToV0({ - input: balance, - legacyData: featureLegacyData, - }); + transformedBalances[featureId] = balanceV1ToV0({ input: balance }); } } @@ -56,12 +48,17 @@ export const V2_0_CustomerChange = defineVersionChange({ .filter((sub) => sub.status === "scheduled") .map((sub) => apiSubscriptionV1ToV0({ input: sub })); + // Convert purchases to subscriptions and add to subscriptions array + const purchasesAsSubscriptions: ApiSubscription[] = ( + input.purchases ?? [] + ).map((purchase) => apiPurchaseV0ToSubscriptionV0({ input: purchase })); + // Return V0 customer format (without purchases field) const { purchases: _purchases, ...rest } = input; return { ...rest, - subscriptions: transformedSubscriptions, + subscriptions: [...transformedSubscriptions, ...purchasesAsSubscriptions], scheduled_subscriptions: transformedScheduledSubscriptions, balances: transformedBalances, }; diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index e21704ecc..12be7487d 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -11,7 +11,6 @@ import type { ApiBalanceBreakdown, ApiBalanceSchema, } from "../apiBalance.js"; -import type { CusFeatureLegacyData } from "../cusFeatureLegacyData.js"; import type { ApiCusFeatureV3Breakdown, ApiCusFeatureV3Schema, @@ -87,13 +86,11 @@ const toV3BalanceParams = ({ input, feature, unlimited, - legacyData, isBreakdown = false, }: { input: ApiBalance | ApiBalanceBreakdown; feature?: ApiFeatureV1; unlimited: boolean; - legacyData?: CusFeatureLegacyData; isBreakdown?: boolean; }) => { const isBoolean = feature?.type === FeatureType.Boolean; @@ -114,7 +111,8 @@ const toV3BalanceParams = ({ prepaidQuantity = (input as ApiBalanceBreakdown).prepaid_quantity ?? 0; } else { prepaidQuantity = sumValues( - (input as ApiBalance).breakdown?.map((b) => b.prepaid_quantity) ?? [], + (input as ApiBalance).breakdown?.map((b) => b.prepaid_quantity ?? 0) ?? + [], ); } @@ -168,10 +166,8 @@ const toV3BalanceParams = ({ export function transformBalanceToCusFeatureV3({ input, - legacyData, }: { input: z.infer; - legacyData?: CusFeatureLegacyData; }): z.infer { // 1. Is boolean feature @@ -189,7 +185,6 @@ export function transformBalanceToCusFeatureV3({ input, feature, unlimited: isUnlimited, - legacyData, }); let newBreakdown: ApiCusFeatureV3Breakdown[] | undefined; @@ -216,7 +211,6 @@ export function transformBalanceToCusFeatureV3({ input: breakdown, feature, unlimited: isUnlimited, - legacyData, isBreakdown: true, }); diff --git a/shared/api/customers/cusFeatures/cusFeatureLegacyData.ts b/shared/api/customers/cusFeatures/cusFeatureLegacyData.ts deleted file mode 100644 index e91859c5d..000000000 --- a/shared/api/customers/cusFeatures/cusFeatureLegacyData.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from "zod/v4"; - -export const CusFeatureLegacyDataSchema = z.object({ - key: z.string().nullable(), - prepaid_quantity: z.number(), - breakdown_legacy_data: z.array( - z.object({ - key: z.string(), - prepaid_quantity: z.number(), - }), - ), -}); - -export type CusFeatureLegacyData = z.infer; diff --git a/shared/api/customers/cusFeatures/index.ts b/shared/api/customers/cusFeatures/index.ts index ba240712a..f6aef23e4 100644 --- a/shared/api/customers/cusFeatures/index.ts +++ b/shared/api/customers/cusFeatures/index.ts @@ -1,6 +1,8 @@ export * from "./apiBalance.js"; -export * from "./cusFeatureLegacyData.js"; +export * from "./apiBalanceV1.js"; export * from "./previousVersions/apiCusFeatureV0.js"; export * from "./previousVersions/apiCusFeatureV1.js"; export * from "./previousVersions/apiCusFeatureV2.js"; export * from "./previousVersions/apiCusFeatureV3.js"; + +export * from "./utils/convert/apiBalanceV1ToAvailableOverage.js"; diff --git a/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts b/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts index ea1d7394f..7d02a8ec8 100644 --- a/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts +++ b/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts @@ -2,7 +2,6 @@ import { deduplicateArray } from "@utils/utils.js"; import { Decimal } from "decimal.js"; import type { ApiBalance, ApiBalanceBreakdown } from "../apiBalance.js"; import type { ApiBalanceBreakdownV1, ApiBalanceV1 } from "../apiBalanceV1.js"; -import type { CusFeatureLegacyData } from "../cusFeatureLegacyData.js"; import { apiBalanceV1ToPrepaidQuantity } from "../utils/convert/apiBalanceV1ToPrepaidQuantity.js"; import { apiBalanceV1ToPurchasedBalance } from "../utils/convert/apiBalanceV1ToPurchasedBalance.js"; @@ -53,13 +52,7 @@ export function balanceBreakdownV1ToV0({ * - `current_balance` = remaining balance * - `reset` = reset interval object */ -export function balanceV1ToV0({ - input, - legacyData: _legacyData, -}: { - input: ApiBalanceV1; - legacyData?: CusFeatureLegacyData; -}): ApiBalance { +export function balanceV1ToV0({ input }: { input: ApiBalanceV1 }): ApiBalance { // Calculate purchased_balance from breakdown const purchasedBalance = apiBalanceV1ToPurchasedBalance({ apiBalance: input, diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts new file mode 100644 index 000000000..b1e0a1987 --- /dev/null +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage.ts @@ -0,0 +1,55 @@ +import { apiBalanceV1ToOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToOverage.js"; +import { BillingMethod } from "@api/products/components/billingMethod.js"; +import { sumValues } from "@utils/utils.js"; +import { Decimal } from "decimal.js"; +import type { + ApiBalanceBreakdownV1, + ApiBalanceV1, +} from "../../apiBalanceV1.js"; + +export const apiBalanceBreakdownV1ToMaxOverage = ({ + apiBalanceBreakdown, +}: { + apiBalanceBreakdown: ApiBalanceBreakdownV1; +}): number | undefined => { + if (apiBalanceBreakdown.price?.billing_method === BillingMethod.UsageBased) { + return apiBalanceBreakdown.price?.max_purchase ?? undefined; + } + + return 0; +}; + +export const apiBalanceV1ToMaxOverage = ({ + apiBalance, +}: { + apiBalance: ApiBalanceV1; +}): number | undefined => { + const breakdownItems = apiBalance.breakdown ?? []; + + const availableOverages = breakdownItems.map((item) => + apiBalanceBreakdownV1ToMaxOverage({ apiBalanceBreakdown: item }), + ); + + if (availableOverages.some((overage) => overage === undefined)) { + return undefined; + } + + return sumValues( + availableOverages.filter((overage) => overage !== undefined), + ); +}; + +export const apiBalanceV1ToAvailableOverage = ({ + apiBalance, +}: { + apiBalance: ApiBalanceV1; +}): number | undefined => { + const maxOverage = apiBalanceV1ToMaxOverage({ apiBalance }); + const overage = apiBalanceV1ToOverage({ apiBalance }); + + if (maxOverage === undefined) { + return undefined; + } + + return Math.max(0, new Decimal(maxOverage).sub(overage).toNumber()); +}; diff --git a/shared/api/customers/cusPlans/apiSubscriptionV1.ts b/shared/api/customers/cusPlans/apiSubscriptionV1.ts index adbbc3a74..0300ab74a 100644 --- a/shared/api/customers/cusPlans/apiSubscriptionV1.ts +++ b/shared/api/customers/cusPlans/apiSubscriptionV1.ts @@ -26,6 +26,7 @@ export const ApiPurchaseV0Schema = z.object({ plan_id: z.string(), expires_at: z.number().nullable(), started_at: z.number(), + quantity: z.number(), }); export type ApiSubscriptionV1 = z.infer; diff --git a/shared/api/customers/cusPlans/index.ts b/shared/api/customers/cusPlans/index.ts index 34e5261df..09885013b 100644 --- a/shared/api/customers/cusPlans/index.ts +++ b/shared/api/customers/cusPlans/index.ts @@ -1,3 +1,5 @@ +import { apiSubscriptionV1ToPurchaseV0 } from "@api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.js"; + export * from "./apiSubscription.js"; export * from "./apiSubscriptionV1.js"; export * from "./cusProductLegacyData.js"; @@ -7,3 +9,9 @@ export * from "./previousVersions/apiCusProductV0.js"; export * from "./previousVersions/apiCusProductV1.js"; export * from "./previousVersions/apiCusProductV2.js"; export * from "./previousVersions/apiCusProductV3.js"; + +export const apiSubscription = { + map: { + v1ToPurchaseV0: apiSubscriptionV1ToPurchaseV0, + }, +}; diff --git a/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts b/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts new file mode 100644 index 000000000..29166b6a2 --- /dev/null +++ b/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts @@ -0,0 +1,29 @@ +import { planV1ToV0 } from "@api/products/mappers/planV1ToV0.js"; +import type { ApiSubscription } from "../apiSubscription.js"; +import type { ApiPurchaseV0 } from "../apiSubscriptionV1.js"; + +/** + * Converts an ApiPurchaseV0 to an ApiSubscription (V0) for backwards compatibility. + * Purchases are represented as subscriptions with sensible defaults for missing fields. + */ +export function apiPurchaseV0ToSubscriptionV0({ + input, +}: { + input: ApiPurchaseV0; +}): ApiSubscription { + return { + plan: input.plan ? planV1ToV0(input.plan) : undefined, + plan_id: input.plan_id, + default: false, + add_on: true, + status: "active", + past_due: false, + canceled_at: null, + expires_at: input.expires_at, + trial_ends_at: null, + started_at: input.started_at, + current_period_start: null, + current_period_end: null, + quantity: input.quantity, + }; +} diff --git a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts index 7d86f49cb..6d474d1b6 100644 --- a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts +++ b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts @@ -12,5 +12,6 @@ export function apiSubscriptionV1ToPurchaseV0({ plan_id: input.plan_id, expires_at: input.expires_at, started_at: input.started_at, + quantity: input.quantity, }; } diff --git a/shared/api/customers/customerLegacyData.ts b/shared/api/customers/customerLegacyData.ts index e58196ca7..89ef0067d 100644 --- a/shared/api/customers/customerLegacyData.ts +++ b/shared/api/customers/customerLegacyData.ts @@ -1,10 +1,8 @@ import { z } from "zod/v4"; -import { CusFeatureLegacyDataSchema } from "./cusFeatures/cusFeatureLegacyData.js"; import { CusProductLegacyDataSchema } from "./cusPlans/cusProductLegacyData.js"; export const CustomerLegacyDataSchema = z.object({ cusProductLegacyData: z.record(z.string(), CusProductLegacyDataSchema), - cusFeatureLegacyData: z.record(z.string(), CusFeatureLegacyDataSchema), }); export type CustomerLegacyData = z.infer; diff --git a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts index 725363dd8..f580f7e5e 100644 --- a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts +++ b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts @@ -53,7 +53,7 @@ export const V1_2_CustomerQueryChange = defineVersionChange({ const newExpand = [ ...existingExpand, CusExpand.SubscriptionsPlan, - CusExpand.ScheduledSubscriptionsPlan, + CusExpand.PurchasesPlan, CusExpand.BalancesFeature, ]; diff --git a/shared/api/entities/apiEntityV2.ts b/shared/api/entities/apiEntityV2.ts index a0cf24af0..0040638e8 100644 --- a/shared/api/entities/apiEntityV2.ts +++ b/shared/api/entities/apiEntityV2.ts @@ -1,13 +1,17 @@ +import { ApiBalanceV1Schema } from "@api/customers/cusFeatures/apiBalanceV1.js"; import { z } from "zod/v4"; -import { ApiBalanceSchema } from "../customers/cusFeatures/apiBalance.js"; -import { ApiSubscriptionV1Schema } from "../customers/cusPlans/apiSubscriptionV1.js"; +import { + ApiPurchaseV0Schema, + ApiSubscriptionV1Schema, +} from "../customers/cusPlans/apiSubscriptionV1.js"; import { ApiInvoiceV1Schema } from "../others/apiInvoice/apiInvoiceV1.js"; import { ApiBaseEntitySchema } from "./apiBaseEntity.js"; // V2 base entity - uses V1 subscriptions (single array with status field) export const BaseApiEntityV2Schema = ApiBaseEntitySchema.extend({ subscriptions: z.array(ApiSubscriptionV1Schema), - balances: z.record(z.string(), ApiBalanceSchema), + purchases: z.array(ApiPurchaseV0Schema), + balances: z.record(z.string(), ApiBalanceV1Schema), }); export const ApiEntityExpandSchema = z.object({ diff --git a/shared/api/entities/changes/V1.2_EntityChange.ts b/shared/api/entities/changes/V1.2_EntityChange.ts index 7f7b3625a..451cbe542 100644 --- a/shared/api/entities/changes/V1.2_EntityChange.ts +++ b/shared/api/entities/changes/V1.2_EntityChange.ts @@ -104,7 +104,6 @@ export const V1_2_EntityChange = defineVersionChange({ for (const [featureId, feature] of Object.entries(input.balances)) { v0_features[featureId] = transformBalanceToCusFeatureV3({ input: feature, - legacyData: legacyData?.cusFeatureLegacyData[featureId], }); } } diff --git a/shared/api/entities/changes/V2.0_EntityChange.ts b/shared/api/entities/changes/V2.0_EntityChange.ts index 9048af552..6789ffbc7 100644 --- a/shared/api/entities/changes/V2.0_EntityChange.ts +++ b/shared/api/entities/changes/V2.0_EntityChange.ts @@ -1,3 +1,5 @@ +import type { ApiBalance } from "@api/customers/cusFeatures/apiBalance.js"; +import { balanceV1ToV0 } from "@api/customers/cusFeatures/mappers/balanceV1ToV0.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -5,10 +7,10 @@ import { } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; import type { ApiSubscription } from "../../customers/cusPlans/apiSubscription.js"; +import { apiPurchaseV0ToSubscriptionV0 } from "../../customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.js"; import { apiSubscriptionV1ToV0 } from "../../customers/cusPlans/mappers/apiSubscriptionV1ToV0.js"; import { ApiEntityV1Schema } from "../apiEntity.js"; import { ApiEntityV2Schema } from "../apiEntityV2.js"; -import { EntityLegacyDataSchema } from "../entityLegacyData.js"; /** * V2.0_EntityChange: Transforms entity response TO V1 format from V2 format @@ -34,14 +36,12 @@ export const V2_0_EntityChange = defineVersionChange({ affectedResources: [AffectedResource.Entity], newSchema: ApiEntityV2Schema, oldSchema: ApiEntityV1Schema, - legacyDataSchema: EntityLegacyDataSchema, affectsResponse: true, transformResponse: ({ input, }: { input: z.infer; - legacyData?: z.infer; }): z.infer => { // Transform subscriptions from V1 to V0 const allSubscriptions = input.subscriptions ?? []; @@ -54,10 +54,26 @@ export const V2_0_EntityChange = defineVersionChange({ .filter((sub) => sub.status === "scheduled") .map((sub) => apiSubscriptionV1ToV0({ input: sub })); + // Convert purchases to subscriptions and add to active subscriptions + const purchasesAsSubscriptions: ApiSubscription[] = ( + input.purchases ?? [] + ).map((purchase) => apiPurchaseV0ToSubscriptionV0({ input: purchase })); + + const balancesV0: Record = {}; + if (input.balances) { + for (const [featureId, balance] of Object.entries(input.balances)) { + balancesV0[featureId] = balanceV1ToV0({ input: balance }); + } + } + + // Return V0 entity format (without purchases field) + const { purchases: _purchases, ...rest } = input; + return { - ...input, - subscriptions: activeSubscriptionsV0, + ...rest, + subscriptions: [...activeSubscriptionsV0, ...purchasesAsSubscriptions], scheduled_subscriptions: scheduledSubscriptionsV0, + balances: balancesV0, }; }, }); diff --git a/shared/api/entities/entityLegacyData.ts b/shared/api/entities/entityLegacyData.ts index 7c8f1f82a..d68c57c12 100644 --- a/shared/api/entities/entityLegacyData.ts +++ b/shared/api/entities/entityLegacyData.ts @@ -1,10 +1,8 @@ import { z } from "zod/v4"; -import { CusFeatureLegacyDataSchema } from "../customers/cusFeatures/cusFeatureLegacyData.js"; import { CusProductLegacyDataSchema } from "../customers/cusPlans/cusProductLegacyData.js"; export const EntityLegacyDataSchema = z.object({ cusProductLegacyData: z.record(z.string(), CusProductLegacyDataSchema), - cusFeatureLegacyData: z.record(z.string(), CusFeatureLegacyDataSchema), }); export type EntityLegacyData = z.infer; diff --git a/shared/api/entities/entityOpModels.ts b/shared/api/entities/entityOpModels.ts index ac3111fe6..f3d736782 100644 --- a/shared/api/entities/entityOpModels.ts +++ b/shared/api/entities/entityOpModels.ts @@ -29,7 +29,7 @@ export const GetEntityQuerySchema = z.object({ z.enum([ CusExpand.Invoices, CusExpand.SubscriptionsPlan, - CusExpand.ScheduledSubscriptionsPlan, + CusExpand.PurchasesPlan, CusExpand.BalancesFeature, ]), ).default([]), diff --git a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts index e8df5ce79..0c7c1d97e 100644 --- a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts +++ b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts @@ -57,7 +57,7 @@ export const V1_2_EntityQueryChange = defineVersionChange({ ...existingExpand, CusExpand.SubscriptionsPlan, CusExpand.BalancesFeature, - CusExpand.ScheduledSubscriptionsPlan, + CusExpand.PurchasesPlan, ] as GetEntityQuery["expand"]; return { diff --git a/shared/api/models.ts b/shared/api/models.ts index 1ca94c0aa..b7b5cd875 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -36,6 +36,7 @@ export * from "./utils/zodToJSDoc.js"; export * from "./balances/balancesUpdateModels.js"; export * from "./balances/check/checkParams.js"; export * from "./balances/check/checkResponseV2.js"; +export * from "./balances/check/checkResponseV3.js"; export * from "./balances/check/enums/CheckExpand.js"; export * from "./balances/check/prevVersions/CheckResponseV0.js"; export * from "./balances/check/prevVersions/CheckResponseV1.js"; @@ -45,6 +46,7 @@ export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; export * from "./balances/track/trackResponseV2.js"; +export * from "./balances/track/trackResponseV3.js"; export * from "./balances/usageModels.js"; // Billing export * from "./billing/index.js"; diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index f34904596..7392459a0 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -29,7 +29,9 @@ import { V2_0_PlanChanges } from "@api/products/changes/V2.0_PlanChanges.js"; import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js"; import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js"; import { V1_2_CheckQueryChange } from "../../balances/check/changes/V1.2_CheckQueryChange.js"; +import { V2_0_CheckChange } from "../../balances/check/changes/V2.0_CheckChange.js"; import { V1_2_TrackChange } from "../../balances/track/changes/V1.2_TrackChange.js"; +import { V2_0_TrackChange } from "../../balances/track/changes/V2.0_TrackChange.js"; import { V1_2_TrackParamsChange } from "../../balances/track/requestChanges/V1.2_TrackParamsChange.js"; // Import attach changes import { V0_2_AttachChange } from "../../billing/attach/changes/V0.2_AttachChange.js"; @@ -38,9 +40,11 @@ import type { VersionChangeConstructor } from "./VersionChange.js"; import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; export const V2_1_CHANGES: VersionChangeConstructor[] = [ - V2_0_PlanChanges, // Transforms Plan TO V2.1 format from V2.0 format - V2_0_CustomerChange, // Transforms Customer TO V2.1 format from V2.0 format - V2_0_EntityChange, // Transforms Entity TO V2.1 format from V2.0 format + V2_0_PlanChanges, // Transforms Plan TO V2.0 format from V2.1 format + V2_0_CustomerChange, // Transforms Customer TO V2.0 format from V2.1 format + V2_0_EntityChange, // Transforms Entity TO V2.0 format from V2.1 format + V2_0_CheckChange, // Transforms Check TO V2.0 format from V2.1 format + V2_0_TrackChange, // Transforms Track TO V2.0 format from V2.1 format ]; export const V2_CHANGES: VersionChangeConstructor[] = [ diff --git a/shared/internal/checkout/checkoutResponses.ts b/shared/internal/checkout/checkoutResponses.ts index 2e29f7b29..10df316e0 100644 --- a/shared/internal/checkout/checkoutResponses.ts +++ b/shared/internal/checkout/checkoutResponses.ts @@ -1,8 +1,8 @@ +import { ApiBalanceV1Schema } from "@api/customers/cusFeatures/apiBalanceV1.js"; import { ApiPlanV1Schema } from "@api/products/apiPlanV1.js"; import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js"; import { z } from "zod/v4"; import { BillingPreviewResponseSchema } from "../../api/billing/common/billingPreviewResponse.js"; -import { ApiBalanceSchema } from "../../api/customers/cusFeatures/apiBalance.js"; /** * Org branding for checkout display @@ -47,7 +47,7 @@ export const CheckoutChangeSchema = z.object({ quantity: true, }), ), - balances: z.record(z.string(), ApiBalanceSchema), + balances: z.record(z.string(), ApiBalanceV1Schema), period_start: z.number().optional(), period_end: z.number().optional(), }); diff --git a/shared/models/cusModels/cusExpand.ts b/shared/models/cusModels/cusExpand.ts index 0d23746a1..be2908f2e 100644 --- a/shared/models/cusModels/cusExpand.ts +++ b/shared/models/cusModels/cusExpand.ts @@ -13,7 +13,7 @@ export enum CusExpand { // PlansPlan = "plans.plan", SubscriptionsPlan = "subscriptions.plan", - ScheduledSubscriptionsPlan = "scheduled_subscriptions.plan", + PurchasesPlan = "purchases.plan", BalancesFeature = "balances.feature", PlanFeaturesFeature = "plan.features.feature", } diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToReset.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToReset.ts index ecb775f89..b31d30826 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToReset.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToReset.ts @@ -1,6 +1,5 @@ import type { ApiBalanceReset } from "../../../api/customers/cusFeatures/apiBalance"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import type { Feature } from "../../../models/featureModels/featureModels"; import { isContUseFeature } from "../../featureUtils/convertFeatureUtils"; import { entIntvToResetIntv, @@ -26,11 +25,13 @@ export const cusEntsToNextResetAt = ({ export const cusEntsToReset = ({ cusEnts, - feature, }: { cusEnts: FullCusEntWithFullCusProduct[]; - feature: Feature; }): ApiBalanceReset | null => { + if (cusEnts.length === 0) return null; + + const feature = cusEnts[0].entitlement.feature; + // 1. If feature is allocated, null if (isContUseFeature({ feature })) return null; diff --git a/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts new file mode 100644 index 000000000..0c1350838 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts @@ -0,0 +1,55 @@ +import type { ApiBalanceBreakdownPrice } from "@api/customers/cusFeatures/apiBalanceV1.js"; +import { BillingMethod } from "@api/products/components/billingMethod.js"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { cusEntsToMaxPurchase } from "@utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js"; +import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.js"; +import { customerPriceToBillingUnits } from "@utils/cusPriceUtils/convertCustomerPrice/customerPriceToBillingUnits.js"; +import { + isFixedPrice, + isPrepaidPrice, + isUsagePrice, +} from "@utils/productUtils/priceUtils/classifyPriceUtils.js"; + +export const customerEntitlementToBalancePrice = ({ + customerEntitlement, +}: { + customerEntitlement: FullCusEntWithFullCusProduct; +}): ApiBalanceBreakdownPrice | null => { + const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); + + if (!cusPrice) return null; + + const price = cusPrice.price; + + const maxPurchase = cusEntsToMaxPurchase({ cusEnts: [customerEntitlement] }); + const billingUnits = customerPriceToBillingUnits({ customerPrice: cusPrice }); + const billingMethod = isPrepaidPrice(price) + ? BillingMethod.Prepaid + : BillingMethod.UsageBased; + + // Determine amount vs tiers + // If fixed price or usage price with single tier, use amount + // If usage price with multiple tiers, use tiers array + let amount: number | undefined; + let tiers: UsagePriceConfig["usage_tiers"] | undefined; + + if (isFixedPrice(price)) { + amount = price.config.amount; + } else if (isUsagePrice({ price })) { + const usageTiers = (price.config as UsagePriceConfig).usage_tiers; + if (usageTiers.length === 1) { + amount = usageTiers[0].amount; + } else { + tiers = usageTiers; + } + } + + return { + amount, + tiers, + billing_units: billingUnits, + billing_method: billingMethod, + max_purchase: maxPurchase, + }; +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverBalance.ts b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverBalance.ts new file mode 100644 index 000000000..754d36649 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverBalance.ts @@ -0,0 +1,28 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { getRolloverFields } from "@utils/cusEntUtils/getRolloverFields.js"; +import { Decimal } from "decimal.js"; +import { sumValues } from "../../../utils.js"; + +const cusEntToRolloverBalance = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; +}) => { + const rollover = getRolloverFields({ cusEnt, entityId }); + + return new Decimal(rollover?.balance ?? 0).toNumber(); +}; + +export const cusEntsToRolloverBalance = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}) => { + return sumValues( + cusEnts.map((cusEnt) => cusEntToRolloverBalance({ cusEnt, entityId })), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverGranted.ts b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverGranted.ts new file mode 100644 index 000000000..501d34115 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverGranted.ts @@ -0,0 +1,30 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { getRolloverFields } from "@utils/cusEntUtils/getRolloverFields.js"; +import { Decimal } from "decimal.js"; +import { sumValues } from "../../../utils.js"; + +const cusEntToRolloverGranted = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; +}) => { + const rollover = getRolloverFields({ cusEnt, entityId }); + + return new Decimal(rollover?.balance ?? 0) + .add(rollover?.usage ?? 0) + .toNumber(); +}; + +export const cusEntsToRolloverGranted = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}) => { + return sumValues( + cusEnts.map((cusEnt) => cusEntToRolloverGranted({ cusEnt, entityId })), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverUsage.ts b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverUsage.ts new file mode 100644 index 000000000..dad211221 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/rollovers/cusEntsToRolloverUsage.ts @@ -0,0 +1,28 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { getRolloverFields } from "@utils/cusEntUtils/getRolloverFields.js"; +import { Decimal } from "decimal.js"; +import { sumValues } from "../../../utils.js"; + +const cusEntToRolloverUsage = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; +}) => { + const rollover = getRolloverFields({ cusEnt, entityId }); + + return new Decimal(rollover?.usage ?? 0).toNumber(); +}; + +export const cusEntsToRolloverUsage = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}) => { + return sumValues( + cusEnts.map((cusEnt) => cusEntToRolloverUsage({ cusEnt, entityId })), + ); +}; diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index d640bdcdd..012f689af 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -8,11 +8,16 @@ export * from "./balanceUtils/cusEntsToRollovers.js"; export * from "./balanceUtils/cusEntsToUsage.js"; export * from "./balanceUtils/cusEntToMinBalance.js"; export * from "./balanceUtils/cusEntToUsageAllowed.js"; - +// Customer entitlement to balance price utils +export * from "./balanceUtils/customerEntitlementToBalancePrice.js"; // Granted balance utils export * from "./balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js"; export * from "./balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js"; export * from "./balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance.js"; +export * from "./balanceUtils/rollovers/cusEntsToRolloverBalance.js"; +export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted.js"; +export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage.js"; +export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage.js"; // Balance utils barrel export * from "./balanceUtils.js"; diff --git a/shared/utils/expandUtils.ts b/shared/utils/expandUtils.ts index 54d4d654e..6537e33c1 100644 --- a/shared/utils/expandUtils.ts +++ b/shared/utils/expandUtils.ts @@ -71,7 +71,7 @@ export const filterPlanAndFeatureExpand = < const expandScheduledSubscriptionPlan = expandIncludes({ expand, - includes: [CusExpand.ScheduledSubscriptionsPlan], + includes: [CusExpand.PurchasesPlan], }); if (!expandScheduledSubscriptionPlan && target.scheduled_subscriptions) {