From cd461bf60d37260bd3ba6fa8e0359288477f1dc6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 13 Mar 2026 14:09:42 +0000 Subject: [PATCH] converted boolean feaures to flags in customer object --- server/src/external/autumn/autumnCli.ts | 20 ++- .../responseFilter/responseFilterConfig.ts | 6 + .../api/check/checkTypes/CheckData.tsx | 3 +- .../api/check/checkUtils/getCheckData.ts | 2 + .../check/checkUtils/getV2CheckResponse.ts | 23 ++- .../createBalance/validateCreateBalance.ts | 2 +- .../apiCusUtils/getApiCustomerBase.ts | 3 +- .../apiCusUtils/getApiCustomerExpand.ts | 3 +- .../customers/handlers/handleGetCustomerV2.ts | 1 + .../internal/entities/actions/findCustomer.ts | 8 +- .../apiEntityUtils/getApiEntityBase.ts | 3 +- .../balances/check/check-basic.test.ts | 20 ++- .../balances/track/basic/track-basic.test.ts | 73 +++++++- .../crud/customers/get-customer.test.ts | 159 ++++++++++++++++++ .../crud/entities/get-entity.test.ts | 140 +++++++++++++++ .../integration/utils/expectBalanceCorrect.ts | 21 ++- .../integration/utils/expectFlagCorrect.ts | 33 ++++ .../check/changes/V1.2_CheckQueryChange.ts | 1 + .../check/changes/V2.0_CheckChange.ts | 7 +- shared/api/balances/check/checkParams.ts | 4 +- shared/api/balances/check/checkResponseV3.ts | 4 + .../api/balances/check/enums/CheckExpand.ts | 1 + shared/api/customers/apiCustomerV5.ts | 13 ++ .../customers/changes/V2.0_CustomerChange.ts | 8 +- .../customerExpand/customerExpand.ts | 1 + .../cusFeatures/utils/getApiBalance.ts | 17 +- .../cusFeatures/utils/getApiBalances.ts | 43 ++++- shared/api/customers/flags/apiFlagV0.ts | 38 +++++ shared/api/customers/flags/index.ts | 3 + .../flags/mappers/flagV0ToBalanceV0.ts | 38 +++++ .../api/customers/flags/utils/getApiFlag.ts | 53 ++++++ shared/api/customers/index.ts | 1 + .../V1.2_CustomerQueryChange.ts | 1 + .../V2.0_CustomerQueryChange.ts | 44 +++++ .../customers/utils/apiCustomerToFeatures.ts | 14 +- shared/api/entities/apiEntityV2.ts | 2 + .../api/entities/changes/V2.0_EntityChange.ts | 7 + .../versionChangeRegistry.ts | 2 + shared/utils/expandUtils.ts | 13 +- 39 files changed, 791 insertions(+), 44 deletions(-) create mode 100644 server/tests/integration/crud/entities/get-entity.test.ts create mode 100644 server/tests/integration/utils/expectFlagCorrect.ts create mode 100644 shared/api/customers/flags/apiFlagV0.ts create mode 100644 shared/api/customers/flags/index.ts create mode 100644 shared/api/customers/flags/mappers/flagV0ToBalanceV0.ts create mode 100644 shared/api/customers/flags/utils/getApiFlag.ts create mode 100644 shared/api/customers/requestChanges/V2.0_CustomerQueryChange.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 3e61197f7..77db23fa0 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -437,14 +437,21 @@ export class AutumnInt { expand?: CustomerExpand[]; skip_cache?: string; with_autumn_id?: boolean; + keepInternalFields?: boolean; }, ): Promise => { + const { keepInternalFields, ...queryParamsInput } = params || {}; const queryParams = new URLSearchParams(); + const headers: Record = {}; const defaultParams = { expand: [CustomerExpand.Invoices], }; - const finalParams = { ...defaultParams, ...params }; + if (keepInternalFields) { + headers["x-strip-internal"] = "false"; + } + + const finalParams = { ...defaultParams, ...queryParamsInput }; if (finalParams.expand) { queryParams.append("expand", finalParams.expand.join(",")); } @@ -459,6 +466,7 @@ export class AutumnInt { } const data = await this.get( `/customers/${customerId}?${queryParams.toString()}`, + Object.keys(headers).length > 0 ? headers : undefined, ); return data; }, @@ -549,14 +557,21 @@ export class AutumnInt { params?: { expand?: EntityExpand[]; skip_cache?: string; + keepInternalFields?: boolean; }, ): Promise => { + const { keepInternalFields, ...queryParamsInput } = params || {}; const queryParams = new URLSearchParams(); + const headers: Record = {}; const defaultParams = { expand: [EntityExpand.Invoices], }; - const finalParams = { ...defaultParams, ...params }; + if (keepInternalFields) { + headers["x-strip-internal"] = "false"; + } + + const finalParams = { ...defaultParams, ...queryParamsInput }; if (finalParams.expand) { queryParams.append("expand", finalParams.expand.join(",")); } @@ -566,6 +581,7 @@ export class AutumnInt { const data = await this.get( `/customers/${customerId}/entities/${entityId}?${queryParams.toString()}`, + Object.keys(headers).length > 0 ? headers : undefined, ); return data as T; }, diff --git a/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts b/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts index 93dee86ce..342ca876a 100644 --- a/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts +++ b/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts @@ -3,6 +3,8 @@ import { ApiBalanceBreakdownV1Schema, type ApiBalanceV1, ApiBalanceV1Schema, + type ApiFlagV0, + ApiFlagV0Schema, type AttachPreviewResponse, AttachPreviewResponseSchema, type BillingPreviewResponse, @@ -78,6 +80,10 @@ const filterConfigs = [ schema: PreviewUpdateSubscriptionResponseSchema, omitFields: ["object"], }), + createFilterConfig({ + schema: ApiFlagV0Schema, + omitFields: ["object"], + }), ]; /** diff --git a/server/src/internal/api/check/checkTypes/CheckData.tsx b/server/src/internal/api/check/checkTypes/CheckData.tsx index 9d2416b0f..25fd52a8e 100644 --- a/server/src/internal/api/check/checkTypes/CheckData.tsx +++ b/server/src/internal/api/check/checkTypes/CheckData.tsx @@ -1,9 +1,10 @@ -import type { ApiBalanceV1, ApiCustomerV5, ApiEntityV2, Feature } from "@autumn/shared"; +import type { ApiBalanceV1, ApiCustomerV5, ApiEntityV2, ApiFlagV0, Feature } from "@autumn/shared"; export interface CheckData { customerId: string; entityId?: string; apiBalance?: ApiBalanceV1; + apiFlag?: ApiFlagV0; apiSubject: ApiCustomerV5 | ApiEntityV2; originalFeature: Feature; featureToUse: Feature; diff --git a/server/src/internal/api/check/checkUtils/getCheckData.ts b/server/src/internal/api/check/checkUtils/getCheckData.ts index 6b56ab78c..216d92e26 100644 --- a/server/src/internal/api/check/checkUtils/getCheckData.ts +++ b/server/src/internal/api/check/checkUtils/getCheckData.ts @@ -113,11 +113,13 @@ export const getCheckData = async ({ }); const apiBalance = apiSubject.balances?.[featureToUse.id]; + const apiFlag = apiSubject.flags?.[featureToUse.id]; return { customerId: customer_id, entityId: entity_id, apiBalance, + apiFlag, apiSubject, originalFeature: feature, featureToUse, diff --git a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts index d5ceb3de6..a43664efa 100644 --- a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts @@ -17,6 +17,7 @@ export const getV2CheckResponse = async ({ customerId, entityId, apiBalance, + apiFlag, apiSubject, originalFeature, featureToUse, @@ -34,28 +35,34 @@ export const getV2CheckResponse = async ({ }); } - if (!apiBalance) { + if (!apiBalance && !apiFlag) { return CheckResponseV3Schema.parse({ allowed: false, customer_id: customerId || "", entity_id: entityId, required_balance: requiredBalance, balance: null, + flag: null, }); } - const allowed = apiBalanceToAllowed({ - apiBalance, - apiSubject, - feature: featureToUse, - requiredBalance, - }); + const allowed = + Boolean(apiFlag) ?? + (apiBalance + ? apiBalanceToAllowed({ + apiBalance, + apiSubject, + feature: featureToUse, + requiredBalance, + }) + : false); return CheckResponseV3Schema.parse({ allowed, customer_id: customerId || "", entity_id: entityId, required_balance: requiredBalance, - balance: apiBalance, + balance: apiBalance ?? null, + flag: apiFlag ?? null, }); }; diff --git a/server/src/internal/balances/createBalance/validateCreateBalance.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts index 41df42c1a..4c0081b40 100644 --- a/server/src/internal/balances/createBalance/validateCreateBalance.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -89,7 +89,7 @@ const validateBooleanEntitlementConflict = async ({ fullCus: fullCustomer, }); - if (apiCustomer.balances?.[feature.id]) { + if (apiCustomer.flags?.[feature.id]) { throw new RecaseError({ message: `A boolean entitlement ${feature.id} already exists for customer ${fullCustomer.internal_id}`, }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 591c630e7..30c5e8974 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -26,7 +26,7 @@ export const getApiCustomerBase = async ({ fullCus: FullCustomer; withAutumnId?: boolean; }): Promise<{ apiCustomer: ApiCustomerV5; legacyData: CustomerLegacyData }> => { - const { data: apiBalances } = await getApiBalances({ + const { balances: apiBalances, flags: apiFlags } = await getApiBalances({ ctx, fullCus, }); @@ -63,6 +63,7 @@ export const getApiCustomerBase = async ({ subscriptions: apiSubscriptions, purchases: apiPurchases, balances: apiBalances, + flags: apiFlags, send_email_receipts: fullCus.send_email_receipts ?? false, billing_controls: { auto_topups: fullCus.auto_topups ?? undefined, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 909f6a4cd..5ae3da3cd 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -24,11 +24,12 @@ export const getApiCustomerExpand = async ({ }): Promise => { const { org, env, db, expand } = ctx; - // Filter out balances.feature and subscriptions.plan + // Filter out synthetic nested expand paths handled within sub-builders. const filteredExpand = filterExpand({ expand, filter: [ CustomerExpand.BalancesFeature, + CustomerExpand.FlagsFeature, CustomerExpand.SubscriptionsPlan, CustomerExpand.Invoices, ], diff --git a/server/src/internal/customers/handlers/handleGetCustomerV2.ts b/server/src/internal/customers/handlers/handleGetCustomerV2.ts index 167f9a186..7fdf4e633 100644 --- a/server/src/internal/customers/handlers/handleGetCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetCustomerV2.ts @@ -15,6 +15,7 @@ import { getOrSetCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/g export const handleGetCustomerV2 = createRoute({ versionedQuery: { latest: GetCustomerQuerySchema, + [ApiVersion.V2_0]: GetCustomerQuerySchema, [ApiVersion.V1_2]: GetCustomerQuerySchema, }, resource: AffectedResource.Customer, diff --git a/server/src/internal/entities/actions/findCustomer.ts b/server/src/internal/entities/actions/findCustomer.ts index 77d438b59..9c544f4f3 100644 --- a/server/src/internal/entities/actions/findCustomer.ts +++ b/server/src/internal/entities/actions/findCustomer.ts @@ -23,7 +23,13 @@ export const findCustomerForEntity = async ({ }); } - if (!entities[0].customer) { + if (entities.length === 0) { + throw new InternalError({ + message: `No entities found for entityId ${entityId}`, + }); + } + + if (!entities?.[0].customer) { throw new InternalError({ message: `[findCustomerForEntity] entities[0].customer doesn't exist`, }); diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index b24a42a5b..37a93b9c1 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -43,7 +43,7 @@ export const getApiEntityBase = async ({ }; // Reuse existing customer functions with filtered products - const { data: apiBalances } = await getApiBalances({ + const { balances: apiBalances, flags: apiFlags } = await getApiBalances({ ctx, fullCus: filteredFullCus, }); @@ -71,6 +71,7 @@ export const getApiEntityBase = async ({ subscriptions: apiSubscriptions, purchases: apiPurchases, balances: apiBalances, + flags: apiFlags, billing_controls: { spend_limits: entity.spend_limits ?? undefined }, } satisfies ApiEntityV2); diff --git a/server/tests/integration/balances/check/check-basic.test.ts b/server/tests/integration/balances/check/check-basic.test.ts index 755842fde..4d1eb19ac 100644 --- a/server/tests/integration/balances/check/check-basic.test.ts +++ b/server/tests/integration/balances/check/check-basic.test.ts @@ -5,6 +5,7 @@ import { type CheckResponseV0, type CheckResponseV1, type CheckResponseV2, + type CheckResponseV3, EntInterval, type LimitedItem, ResetInterval, @@ -93,12 +94,29 @@ test.concurrent(`${chalk.yellowBright("check-boolean: /check on boolean feature" const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); - const { customerId, autumnV1, autumnV2 } = await initScenario({ + const { customerId, autumnV1, autumnV2, autumnV2_1 } = await initScenario({ customerId: "check-boolean", setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], actions: [s.attach({ productId: freeProd.id })], }); + const resV2_1 = (await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponseV3; + + expect(resV2_1).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: null, + flag: { + plan_id: freeProd.id, + feature_id: TestFeature.Dashboard, + expires_at: null, + }, + }); + // v2 response const resV2 = (await autumnV2.check({ customer_id: customerId, diff --git a/server/tests/integration/balances/track/basic/track-basic.test.ts b/server/tests/integration/balances/track/basic/track-basic.test.ts index 174701fc3..22e1fd89c 100644 --- a/server/tests/integration/balances/track/basic/track-basic.test.ts +++ b/server/tests/integration/balances/track/basic/track-basic.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV3, TrackResponseV2 } from "@autumn/shared"; +import { track } from "@tests/_groups/domains/balances/track"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -188,10 +189,76 @@ test.concurrent(`${chalk.yellowBright("track-basic3: track specific feature_id o }); // ═══════════════════════════════════════════════════════════════════ -// TRACK-BASIC4: Track with unlimited balance +// TRACK-BASIC4: Track boolean feature is a no-op // ═══════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("track-basic4: track with unlimited balance")}`, async () => { +test.concurrent(`${chalk.yellowBright("track-basic4: track boolean feature is a no-op")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ + id: "free-boolean-track", + items: [dashboardItem, messagesItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-basic4-boolean", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.Dashboard]).toMatchObject({ + balance: 0, + usage: 0, + }); + expect(customerBefore.features[TestFeature.Messages]).toMatchObject({ + balance: 100, + usage: 0, + }); + + const trackRes: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + }); + + expect(trackRes).toMatchObject({ + customer_id: customerId, + value: 1, + balance: null, + }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Dashboard]).toMatchObject({ + balance: 0, + usage: 0, + }); + expect(customer.features[TestFeature.Messages]).toMatchObject({ + balance: 100, + usage: 0, + }); + + await timeout(2000); + + const customerNonCached = await autumnV1.customers.get( + customerId, + { skip_cache: "true" }, + ); + expect(customerNonCached.features[TestFeature.Dashboard]).toMatchObject({ + balance: 0, + usage: 0, + }); + expect(customerNonCached.features[TestFeature.Messages]).toMatchObject({ + balance: 100, + usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-BASIC5: Track with unlimited balance +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-basic5: track with unlimited balance")}`, async () => { const unlimitedMessagesItem = items.unlimitedMessages(); const freeProd = products.base({ id: "free", @@ -199,7 +266,7 @@ test.concurrent(`${chalk.yellowBright("track-basic4: track with unlimited balanc }); const { customerId, autumnV1, autumnV2 } = await initScenario({ - customerId: "track-basic4", + customerId: "track-basic5", setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], actions: [s.attach({ productId: freeProd.id })], }); diff --git a/server/tests/integration/crud/customers/get-customer.test.ts b/server/tests/integration/crud/customers/get-customer.test.ts index 5830fd36a..c63e7b671 100644 --- a/server/tests/integration/crud/customers/get-customer.test.ts +++ b/server/tests/integration/crud/customers/get-customer.test.ts @@ -1,4 +1,16 @@ import { expect, test } from "bun:test"; +import { + type ApiCustomer, + ApiCustomerSchema, + CustomerExpand, +} from "@autumn/shared"; +import { + type ApiCustomerV5, + ApiCustomerV5Schema, +} from "@shared/api/customers/apiCustomerV5"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +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"; @@ -29,3 +41,150 @@ test.concurrent(`${chalk.yellowBright("get-customer: expand empty array returns expect(customer.products[0].items).toBeDefined(); expect(customer.products[0].items!.length).toBeGreaterThan(0); }); + +test.concurrent(`${chalk.yellowBright("get-customer: v2.1 returns boolean features in flags")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "flags-pro", + items: [dashboardItem, messagesItem], + }); + + const customerId = "get-customer-flags-v2-1"; + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customer = await autumnV2_1.customers.get(customerId, { + expand: [CustomerExpand.FlagsFeature], + keepInternalFields: true, + }); + + ApiCustomerV5Schema.parse(customer); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + expiresAt: null, + withFeature: true, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 100, + planId: pro.id, + }); + expect(customer.balances[TestFeature.Dashboard]).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("get-customer: v2 returns boolean features in balances")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "flags-pro-v2", + items: [dashboardItem, messagesItem], + }); + + const customerId = "get-customer-flags-v2"; + + const { autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customer = await autumnV2.customers.get(customerId); + + ApiCustomerSchema.parse(customer); + expect(customer.balances[TestFeature.Dashboard]).toMatchObject({ + feature_id: TestFeature.Dashboard, + plan_id: pro.id, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + overage_allowed: false, + max_purchase: null, + reset: null, + }); + expect(customer.balances[TestFeature.Messages]).toMatchObject({ + feature_id: TestFeature.Messages, + plan_id: pro.id, + granted_balance: 100, + purchased_balance: 0, + current_balance: 100, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("get-customer: created boolean balance is returned as flag with expires_at")}`, async () => { + const customerId = "get-customer-created-flag-v2-1"; + const expiresAt = Date.now() + 60_000; + + const { autumnV2, autumnV2_1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + expires_at: expiresAt, + }); + + const customer = await autumnV2_1.customers.get(customerId, { + keepInternalFields: true, + }); + + ApiCustomerV5Schema.parse(customer); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: null, + expiresAt, + }); + expect(customer.balances[TestFeature.Dashboard]).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("get-customer: v2 balances.feature also expands flag features")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "flags-pro-v2-expand", + items: [dashboardItem, messagesItem], + }); + + const customerId = "get-customer-flags-v2-expand"; + + const { autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customer = await autumnV2.customers.get(customerId, { + expand: [CustomerExpand.BalancesFeature], + keepInternalFields: true, + }); + + ApiCustomerSchema.parse(customer); + expect(customer.balances[TestFeature.Dashboard].feature?.id).toBe( + TestFeature.Dashboard, + ); + expect(customer.balances[TestFeature.Messages].feature?.id).toBe( + TestFeature.Messages, + ); +}); diff --git a/server/tests/integration/crud/entities/get-entity.test.ts b/server/tests/integration/crud/entities/get-entity.test.ts new file mode 100644 index 000000000..82a2e83fe --- /dev/null +++ b/server/tests/integration/crud/entities/get-entity.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { + type ApiEntityV1, + ApiEntityV1Schema, + type ApiEntityV2, +} from "@autumn/shared"; +import { ApiEntityV2Schema } from "@shared/api/entities/apiEntityV2"; +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"; + +test.concurrent(`${chalk.yellowBright("get-entity: v2.1 returns boolean features in flags")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "entity-flags-pro", + items: [dashboardItem, messagesItem], + }); + + const customerId = "get-entity-flags-v2-1"; + + const { autumnV2_1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entity = await autumnV2_1.entities.get( + customerId, + entities[0].id, + { + keepInternalFields: true, + }, + ); + + ApiEntityV2Schema.parse(entity); + expect(entity.flags[TestFeature.Dashboard]).toMatchObject({ + feature_id: TestFeature.Dashboard, + plan_id: pro.id, + expires_at: null, + }); + expect(entity.balances[TestFeature.Messages]).toMatchObject({ + feature_id: TestFeature.Messages, + granted: 100, + remaining: 100, + usage: 0, + }); + expect(entity.balances[TestFeature.Dashboard]).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("get-entity: v2 returns boolean features in balances")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "entity-flags-pro-v2", + items: [dashboardItem, messagesItem], + }); + + const customerId = "get-entity-flags-v2"; + + const { autumnV2, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entity = await autumnV2.entities.get( + customerId, + entities[0].id, + ); + + ApiEntityV1Schema.parse(entity); + expect(entity.balances).toBeDefined(); + expect(entity.balances?.[TestFeature.Dashboard]).toMatchObject({ + feature_id: TestFeature.Dashboard, + plan_id: pro.id, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + overage_allowed: false, + max_purchase: null, + reset: null, + }); + expect(entity.balances?.[TestFeature.Messages]).toMatchObject({ + feature_id: TestFeature.Messages, + plan_id: pro.id, + granted_balance: 100, + purchased_balance: 0, + current_balance: 100, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("get-entity: created boolean balance is returned as flag with expires_at")}`, async () => { + const customerId = "get-entity-created-flag-v2-1"; + const expiresAt = Date.now() + 60_000; + + const { autumnV2, autumnV2_1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Dashboard, + expires_at: expiresAt, + }); + + const entity = await autumnV2_1.entities.get( + customerId, + entities[0].id, + { + keepInternalFields: true, + }, + ); + + ApiEntityV2Schema.parse(entity); + expect(entity.flags[TestFeature.Dashboard]).toMatchObject({ + feature_id: TestFeature.Dashboard, + plan_id: null, + expires_at: expiresAt, + }); + expect(entity.balances[TestFeature.Dashboard]).toBeUndefined(); +}); diff --git a/server/tests/integration/utils/expectBalanceCorrect.ts b/server/tests/integration/utils/expectBalanceCorrect.ts index be7dfda76..1e115e655 100644 --- a/server/tests/integration/utils/expectBalanceCorrect.ts +++ b/server/tests/integration/utils/expectBalanceCorrect.ts @@ -20,21 +20,34 @@ export const expectBalanceCorrect = ({ customer, featureId, remaining, + planId, + usage, breakdown, rollovers, }: { customer: ApiCustomerV5; featureId: string; remaining: number; + planId?: string | null; + usage?: number; breakdown?: BreakdownExpectation; /** Expected rollovers in order (oldest first). Only specified fields are checked. */ rollovers?: Partial[]; }) => { - expect(customer.balances[featureId]).toBeDefined(); - expect(customer.balances[featureId].remaining).toBe(remaining); + const balance = customer.balances[featureId]; + expect(balance).toBeDefined(); + expect(balance.remaining).toBe(remaining); + + if (typeof planId !== "undefined") { + expect(balance.breakdown?.[0]?.plan_id ?? null).toBe(planId); + } + + if (typeof usage !== "undefined") { + expect(balance.usage).toBe(usage); + } if (breakdown) { - const buckets = customer.balances[featureId]?.breakdown; + const buckets = balance.breakdown; expect(buckets).toBeDefined(); for (const [key, expectation] of Object.entries(breakdown)) { @@ -48,7 +61,7 @@ export const expectBalanceCorrect = ({ } if (rollovers) { - const actual = customer.balances[featureId]?.rollovers; + const actual = balance.rollovers; expect(actual?.length).toBe(rollovers.length); for (let i = 0; i < rollovers.length; i++) { expect(actual![i]).toMatchObject(rollovers[i]); diff --git a/server/tests/integration/utils/expectFlagCorrect.ts b/server/tests/integration/utils/expectFlagCorrect.ts new file mode 100644 index 000000000..afcc97cb8 --- /dev/null +++ b/server/tests/integration/utils/expectFlagCorrect.ts @@ -0,0 +1,33 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; + +export const expectFlagCorrect = ({ + customer, + featureId, + planId, + expiresAt, + withFeature, +}: { + customer: ApiCustomerV5; + featureId: string; + planId?: string | null; + expiresAt?: number | null; + withFeature?: boolean; +}) => { + const flag = customer.flags[featureId]; + + expect(flag).toBeDefined(); + expect(flag.feature_id).toBe(featureId); + + if (typeof planId !== "undefined") { + expect(flag.plan_id).toBe(planId); + } + + if (typeof expiresAt !== "undefined") { + expect(flag.expires_at).toBe(expiresAt); + } + + if (withFeature) { + expect(flag.feature?.id).toBe(featureId); + } +}; diff --git a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts index 5247cdc80..0c2c7d9a9 100644 --- a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts +++ b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts @@ -56,6 +56,7 @@ export const V1_2_CheckQueryChange = defineVersionChange({ const newExpand: CheckExpand[] = [ ...(existingExpand as CheckExpand[]), CheckExpand.BalanceFeature, + CheckExpand.FlagFeature, ]; return { diff --git a/shared/api/balances/check/changes/V2.0_CheckChange.ts b/shared/api/balances/check/changes/V2.0_CheckChange.ts index 837b9daed..a35c00911 100644 --- a/shared/api/balances/check/changes/V2.0_CheckChange.ts +++ b/shared/api/balances/check/changes/V2.0_CheckChange.ts @@ -1,3 +1,4 @@ +import { flagV0ToBalanceV0 } from "@api/models.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -33,7 +34,11 @@ export const V2_0_CheckChange = defineVersionChange({ }): z.infer => { return { ...input, - balance: input.balance ? balanceV1ToV0({ input: input.balance }) : null, + balance: input.balance + ? balanceV1ToV0({ input: input.balance }) + : input.flag + ? flagV0ToBalanceV0({ input: input.flag }) + : null, }; }, }); diff --git a/shared/api/balances/check/checkParams.ts b/shared/api/balances/check/checkParams.ts index 768e5de4a..93c13e243 100644 --- a/shared/api/balances/check/checkParams.ts +++ b/shared/api/balances/check/checkParams.ts @@ -8,7 +8,9 @@ import { CheckExpand } from "./enums/CheckExpand"; export const CheckQuerySchema = z.object({ skip_cache: z.boolean().optional(), - expand: queryStringArray(z.enum([CheckExpand.BalanceFeature])).optional(), + expand: queryStringArray( + z.enum([CheckExpand.BalanceFeature, CheckExpand.FlagFeature]), + ).optional(), }); // Check Feature Schemas diff --git a/shared/api/balances/check/checkResponseV3.ts b/shared/api/balances/check/checkResponseV3.ts index 282e4cd78..ea19623e1 100644 --- a/shared/api/balances/check/checkResponseV3.ts +++ b/shared/api/balances/check/checkResponseV3.ts @@ -1,3 +1,4 @@ +import { ApiFlagV0Schema } from "@api/models.js"; import { z } from "zod/v4"; import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; import { CheckFeaturePreviewSchema } from "./checkFeaturePreview.js"; @@ -26,6 +27,9 @@ export const CheckResponseV3Schema = z.object({ description: "The customer's balance for this feature. Null if the customer has no balance for this feature.", }), + flag: ApiFlagV0Schema.nullable().meta({ + description: "The flag associated with this check, if any.", + }), // lock_id: z.string().optional().meta({ // description: diff --git a/shared/api/balances/check/enums/CheckExpand.ts b/shared/api/balances/check/enums/CheckExpand.ts index 0556643ce..6dcb942c1 100644 --- a/shared/api/balances/check/enums/CheckExpand.ts +++ b/shared/api/balances/check/enums/CheckExpand.ts @@ -1,3 +1,4 @@ export enum CheckExpand { BalanceFeature = "balance.feature", + FlagFeature = "flag.feature", } diff --git a/shared/api/customers/apiCustomerV5.ts b/shared/api/customers/apiCustomerV5.ts index fb0766faa..ef5d05036 100644 --- a/shared/api/customers/apiCustomerV5.ts +++ b/shared/api/customers/apiCustomerV5.ts @@ -6,6 +6,7 @@ import { ApiPurchaseV0Schema, ApiSubscriptionV1Schema, } from "./cusPlans/apiSubscriptionV1"; +import { ApiFlagV0Schema } from "./flags/apiFlagV0"; export const API_CUSTOMER_V5_EXAMPLE = { id: "2ee25a41-0d81-4ad2-8451-ec1aadaefe58", // Example UUID @@ -66,6 +67,14 @@ export const API_CUSTOMER_V5_EXAMPLE = { ], }, }, + flags: { + advanced_workflows: { + id: "cus_ent_abc123", + plan_id: "pro_plan", + expires_at: null, + feature_id: "advanced_workflows", + }, + }, }; // V5 base customer - uses V1 subscriptions (single array with status field) and V1 balances @@ -81,6 +90,10 @@ export const BaseApiCustomerV5Schema = BaseApiCustomerSchema.extend({ description: "Feature balances keyed by feature ID, showing usage limits and remaining amounts.", }), + flags: z.record(z.string(), ApiFlagV0Schema).meta({ + description: + "Boolean feature flags keyed by feature ID, showing enabled access for on/off features.", + }), }).meta({ examples: [API_CUSTOMER_V5_EXAMPLE], }); diff --git a/shared/api/customers/changes/V2.0_CustomerChange.ts b/shared/api/customers/changes/V2.0_CustomerChange.ts index df5fbc3d6..0567273e1 100644 --- a/shared/api/customers/changes/V2.0_CustomerChange.ts +++ b/shared/api/customers/changes/V2.0_CustomerChange.ts @@ -12,6 +12,7 @@ import { balanceV1ToV0 } from "../cusFeatures/mappers/balanceV1ToV0"; import type { ApiSubscription } from "../cusPlans/apiSubscription"; import { apiPurchasesV0ToSubscriptionsV0 } from "../cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0"; import { apiSubscriptionsV1ToV0 } from "../cusPlans/mappers/apiSubscriptionsV1ToV0"; +import { flagV0ToBalanceV0 } from "../flags/mappers/flagV0ToBalanceV0"; export const V2_0_CustomerChange = defineVersionChange({ name: "V2_0 Customer Change", @@ -30,7 +31,6 @@ export const V2_0_CustomerChange = defineVersionChange({ ctx: SharedContext; input: 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)) { @@ -38,6 +38,12 @@ export const V2_0_CustomerChange = defineVersionChange({ } } + if (input.flags) { + for (const [featureId, flag] of Object.entries(input.flags)) { + transformedBalances[featureId] = flagV0ToBalanceV0({ input: flag }); + } + } + const mergedSubscriptions = apiSubscriptionsV1ToV0({ ctx, input: input.subscriptions ?? [], diff --git a/shared/api/customers/components/customerExpand/customerExpand.ts b/shared/api/customers/components/customerExpand/customerExpand.ts index ae763cbcd..5c9a60c8c 100644 --- a/shared/api/customers/components/customerExpand/customerExpand.ts +++ b/shared/api/customers/components/customerExpand/customerExpand.ts @@ -11,6 +11,7 @@ export enum CustomerExpand { SubscriptionsPlan = "subscriptions.plan", PurchasesPlan = "purchases.plan", BalancesFeature = "balances.feature", + FlagsFeature = "flags.feature", } export const CustomerExpandEnum = z.enum(CustomerExpand).meta({ diff --git a/shared/api/customers/cusFeatures/utils/getApiBalance.ts b/shared/api/customers/cusFeatures/utils/getApiBalance.ts index 1dd48e7c0..c77b48c97 100644 --- a/shared/api/customers/cusFeatures/utils/getApiBalance.ts +++ b/shared/api/customers/cusFeatures/utils/getApiBalance.ts @@ -20,6 +20,7 @@ import { customerEntitlementToBalancePrice, dbToApiFeatureV1, expandIncludes, + expandPathIncludes, type Feature, FeatureType, type FullCusEntWithFullCusProduct, @@ -28,6 +29,7 @@ import { isUnlimitedCusEnt, nullish, type SharedContext, + scopeExpandForCtx, sumValues, } from "@autumn/shared"; import { AllowanceType } from "@models/productModels/entModels/entModels.js"; @@ -64,9 +66,11 @@ const getUnlimitedAndUsageAllowed = ({ }; const getApiBalanceBreakdownItem = ({ + ctx, fullCus, customerEntitlement, }: { + ctx: SharedContext; fullCus: FullCustomer; customerEntitlement: FullCusEntWithFullCusProduct; }): ApiBalanceBreakdownV1 => { @@ -98,6 +102,11 @@ const getApiBalanceBreakdownItem = ({ entityId, }); + const includePrice = expandPathIncludes({ + expand: ctx.expand, + includes: ["breakdown.price"], + }); + return { object: "balance_breakdown", id: customerEntitlement.external_id ?? customerEntitlement.id, @@ -129,7 +138,11 @@ export const getApiBalance = ({ const apiFeature = expandIncludes({ expand: ctx.expand, - includes: [CheckExpand.BalanceFeature, CustomerExpand.BalancesFeature], + includes: [ + CheckExpand.BalanceFeature, + CustomerExpand.BalancesFeature, + "feature", + ], }) ? dbToApiFeatureV1({ ctx, dbFeature: feature }) : undefined; @@ -163,7 +176,7 @@ export const getApiBalance = ({ ); const breakdownItems = cusEnts.map((cusEnt) => - getApiBalanceBreakdownItem({ fullCus, customerEntitlement: cusEnt }), + getApiBalanceBreakdownItem({ ctx, fullCus, customerEntitlement: cusEnt }), ); const totalGranted = sumValues( breakdownItems.map((item) => diff --git a/shared/api/customers/cusFeatures/utils/getApiBalances.ts b/shared/api/customers/cusFeatures/utils/getApiBalances.ts index f71428082..5ca9e7f67 100644 --- a/shared/api/customers/cusFeatures/utils/getApiBalances.ts +++ b/shared/api/customers/cusFeatures/utils/getApiBalances.ts @@ -1,11 +1,15 @@ import { type ApiBalanceV1, + type ApiFlagV0, + FeatureType, type FullCusEntWithFullCusProduct, type FullCustomer, fullCustomerToCustomerEntitlements, orgToInStatuses, type SharedContext, + scopeExpandForCtx, } from "@autumn/shared"; +import { getApiFlag } from "../../flags/utils/getApiFlag.js"; import { getApiBalance } from "./getApiBalance.js"; export const getApiBalances = async ({ @@ -14,7 +18,10 @@ export const getApiBalances = async ({ }: { ctx: SharedContext; fullCus: FullCustomer; -}): Promise<{ data: Record }> => { +}): Promise<{ + balances: Record; + flags: Record; +}> => { const allCusEnts = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, inStatuses: orgToInStatuses({ org: ctx.org }), @@ -30,20 +37,46 @@ export const getApiBalances = async ({ ]; } - const apiCusFeatures: Record = {}; + const apiBalances: Record = {}; + const apiFlags: Record = {}; + + const flagScopedCtx = scopeExpandForCtx({ + ctx, + prefix: ["flags", "flag"], + }); + + const balancesScopedCtx = scopeExpandForCtx({ + ctx, + prefix: ["balances", "balance"], + }); + for (const key in featureToCusEnt) { const feature = featureToCusEnt[key][0].entitlement.feature; const cusEnts = featureToCusEnt[key]; + if (feature.type === FeatureType.Boolean) { + const { data } = getApiFlag({ + ctx: flagScopedCtx, + cusEnts, + feature, + }); + + apiFlags[feature.id] = data; + continue; + } + const { data } = getApiBalance({ - ctx, + ctx: balancesScopedCtx, fullCus, cusEnts, feature, }); - apiCusFeatures[feature.id] = data; + apiBalances[feature.id] = data; } - return { data: apiCusFeatures }; + return { + balances: apiBalances, + flags: apiFlags, + }; }; diff --git a/shared/api/customers/flags/apiFlagV0.ts b/shared/api/customers/flags/apiFlagV0.ts new file mode 100644 index 000000000..67b3fd130 --- /dev/null +++ b/shared/api/customers/flags/apiFlagV0.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; +import { ApiFeatureV1Schema } from "../../features/apiFeatureV1"; + +export const API_FLAG_V0_EXAMPLE = { + id: "cus_ent_39qmLooixXLAqMywgXywjAz96rV", + plan_id: "pro_plan", + expires_at: null, + feature_id: "dashboard", +}; + +export const ApiFlagV0Schema = z + .object({ + object: z.literal("flag").meta({ + internal: true, + }), + id: z.string().meta({ + description: "The unique identifier for this flag.", + }), + plan_id: z.string().nullable().meta({ + description: + "The plan ID this flag originates from, or null for standalone flags.", + }), + expires_at: z.number().nullable().meta({ + description: + "Timestamp when this flag expires, or null for no expiration.", + }), + feature_id: z.string().meta({ + description: "The feature ID this flag is for.", + }), + feature: ApiFeatureV1Schema.optional().meta({ + description: "The full feature object if expanded.", + }), + }) + .meta({ + examples: [API_FLAG_V0_EXAMPLE], + }); + +export type ApiFlagV0 = z.infer; diff --git a/shared/api/customers/flags/index.ts b/shared/api/customers/flags/index.ts new file mode 100644 index 000000000..36e4cd806 --- /dev/null +++ b/shared/api/customers/flags/index.ts @@ -0,0 +1,3 @@ +export * from "./apiFlagV0"; +export * from "./mappers/flagV0ToBalanceV0"; +export * from "./utils/getApiFlag"; diff --git a/shared/api/customers/flags/mappers/flagV0ToBalanceV0.ts b/shared/api/customers/flags/mappers/flagV0ToBalanceV0.ts new file mode 100644 index 000000000..8201b1759 --- /dev/null +++ b/shared/api/customers/flags/mappers/flagV0ToBalanceV0.ts @@ -0,0 +1,38 @@ +import type { ApiBalance } from "../../cusFeatures/apiBalance"; +import type { ApiFlagV0 } from "../apiFlagV0"; + +export const flagV0ToBalanceV0 = ({ + input, +}: { + input: ApiFlagV0; +}): ApiBalance => { + return { + feature_id: input.feature_id, + feature: input.feature, + unlimited: false, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + overage_allowed: false, + max_purchase: null, + reset: null, + plan_id: input.plan_id, + breakdown: [ + { + id: input.id, + plan_id: input.plan_id, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + overage_allowed: false, + max_purchase: null, + reset: null, + prepaid_quantity: 0, + expires_at: input.expires_at, + }, + ], + rollovers: undefined, + }; +}; diff --git a/shared/api/customers/flags/utils/getApiFlag.ts b/shared/api/customers/flags/utils/getApiFlag.ts new file mode 100644 index 000000000..2bf4df9c4 --- /dev/null +++ b/shared/api/customers/flags/utils/getApiFlag.ts @@ -0,0 +1,53 @@ +import { + CustomerExpand, + cusEntsToPlanId, + dbToApiFeatureV1, + expandIncludes, + expandPathIncludes, + type Feature, + type FullCusEntWithFullCusProduct, + type SharedContext, + scopeExpandForCtx, +} from "@autumn/shared"; +import type { ApiFlagV0 } from "../apiFlagV0"; + +export const getApiFlag = ({ + ctx, + cusEnts, + feature, +}: { + ctx: SharedContext; + cusEnts: FullCusEntWithFullCusProduct[]; + feature: Feature; +}): { data: ApiFlagV0 } => { + const featureCtx = scopeExpandForCtx({ + ctx, + prefix: "feature", + }); + + const shouldExpandFeature = expandPathIncludes({ + expand: ctx.expand, + includes: ["feature"], + }); + + const apiFeature = shouldExpandFeature + ? dbToApiFeatureV1({ + ctx: featureCtx, + dbFeature: feature, + }) + : undefined; + + const primaryCustomerEntitlement = cusEnts[0]; + + return { + data: { + object: "flag", + id: + primaryCustomerEntitlement.external_id ?? primaryCustomerEntitlement.id, + plan_id: cusEntsToPlanId({ cusEnts }), + expires_at: primaryCustomerEntitlement.expires_at, + feature_id: feature.id, + feature: apiFeature, + }, + }; +}; diff --git a/shared/api/customers/index.ts b/shared/api/customers/index.ts index e150e6234..ce07e17f5 100644 --- a/shared/api/customers/index.ts +++ b/shared/api/customers/index.ts @@ -11,6 +11,7 @@ export * from "./cusFeatures/index"; export * from "./cusPlans/index"; export * from "./customerLegacyData"; export * from "./customerOpModels"; +export * from "./flags/index"; export * from "./previousVersions/index"; // NOTE: changes/ and requestChanges/ are NOT exported here to avoid circular imports diff --git a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts index 29bed6426..ddf2bfae2 100644 --- a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts +++ b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts @@ -58,6 +58,7 @@ export const V1_2_CustomerQueryChange = defineVersionChange({ CustomerExpand.SubscriptionsPlan, CustomerExpand.PurchasesPlan, CustomerExpand.BalancesFeature, + CustomerExpand.FlagsFeature, ]; return { diff --git a/shared/api/customers/requestChanges/V2.0_CustomerQueryChange.ts b/shared/api/customers/requestChanges/V2.0_CustomerQueryChange.ts new file mode 100644 index 000000000..e6e099c96 --- /dev/null +++ b/shared/api/customers/requestChanges/V2.0_CustomerQueryChange.ts @@ -0,0 +1,44 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange"; +import type { z } from "zod/v4"; +import type { SharedContext } from "../../../types/sharedContext"; +import { CustomerExpand } from "../components/customerExpand/customerExpand"; +import { GetCustomerQuerySchema } from "../customerOpModels"; + +export const V2_0_CustomerQueryChange = defineVersionChange({ + newVersion: ApiVersion.V2_1, + oldVersion: ApiVersion.V2_0, + description: [ + "Automatically expands flags.feature when balances.feature is requested by V2.0 clients", + ], + affectedResources: [AffectedResource.Customer], + newSchema: GetCustomerQuerySchema, + oldSchema: GetCustomerQuerySchema, + affectsRequest: true, + affectsResponse: false, + transformRequest: ({ + ctx: _ctx, + input, + }: { + ctx: SharedContext; + input: z.infer; + }) => { + const existingExpand = input.expand || []; + + if (!existingExpand.includes(CustomerExpand.BalancesFeature)) { + return input; + } + + if (existingExpand.includes(CustomerExpand.FlagsFeature)) { + return input; + } + + return { + ...input, + expand: [...existingExpand, CustomerExpand.FlagsFeature], + } satisfies z.infer; + }, +}); diff --git a/shared/api/customers/utils/apiCustomerToFeatures.ts b/shared/api/customers/utils/apiCustomerToFeatures.ts index 7d41fa4a2..0a26c19bd 100644 --- a/shared/api/customers/utils/apiCustomerToFeatures.ts +++ b/shared/api/customers/utils/apiCustomerToFeatures.ts @@ -7,16 +7,18 @@ export const apiCustomerToFeatures = ({ apiCustomer: ApiCustomerV5; }): ApiFeatureV1[] => { const balances = Object.values(apiCustomer.balances); - if (balances.length === 0) return []; + const flags = Object.values(apiCustomer.flags); + const customerStates = [...balances, ...flags]; + if (customerStates.length === 0) return []; - const firstBalance = balances[0]; - if (!firstBalance.feature) { + const firstCustomerState = customerStates[0]; + if (!firstCustomerState.feature) { throw new Error( - "[apiCustomerToFeatures] please expand `balances.feature` to get features for the customer", + "[apiCustomerToFeatures] please expand `balances.feature` or `flags.feature` to get features for the customer", ); } - return Object.values(apiCustomer.balances) - .map((balance) => balance.feature) + return customerStates + .map((customerState) => customerState.feature) .filter((feature): feature is ApiFeatureV1 => feature !== undefined); }; diff --git a/shared/api/entities/apiEntityV2.ts b/shared/api/entities/apiEntityV2.ts index 2f1570fd6..321eb6f4d 100644 --- a/shared/api/entities/apiEntityV2.ts +++ b/shared/api/entities/apiEntityV2.ts @@ -1,4 +1,5 @@ import { ApiBalanceV1Schema } from "@api/customers/cusFeatures/apiBalanceV1.js"; +import { ApiFlagV0Schema } from "@api/customers/flags/apiFlagV0.js"; import { z } from "zod/v4"; import { ApiEntityBillingControlsSchema } from "../billingControls/entityBillingControls.js"; import { @@ -13,6 +14,7 @@ export const BaseApiEntityV2Schema = ApiBaseEntitySchema.extend({ subscriptions: z.array(ApiSubscriptionV1Schema), purchases: z.array(ApiPurchaseV0Schema), balances: z.record(z.string(), ApiBalanceV1Schema), + flags: z.record(z.string(), ApiFlagV0Schema), billing_controls: ApiEntityBillingControlsSchema.optional().meta({ description: "Billing controls for the entity.", }), diff --git a/shared/api/entities/changes/V2.0_EntityChange.ts b/shared/api/entities/changes/V2.0_EntityChange.ts index 1d0196d7a..b03d9a370 100644 --- a/shared/api/entities/changes/V2.0_EntityChange.ts +++ b/shared/api/entities/changes/V2.0_EntityChange.ts @@ -2,6 +2,7 @@ import type { ApiBalance } from "@api/customers/cusFeatures/apiBalance"; import { balanceV1ToV0 } from "@api/customers/cusFeatures/mappers/balanceV1ToV0"; import { apiPurchasesV0ToSubscriptionsV0 } from "@api/customers/cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0"; import { apiSubscriptionsV1ToV0 } from "@api/customers/cusPlans/mappers/apiSubscriptionsV1ToV0"; +import { flagV0ToBalanceV0 } from "@api/customers/flags/mappers/flagV0ToBalanceV0"; import { ApiVersion } from "@api/versionUtils/ApiVersion"; import { AffectedResource, @@ -77,6 +78,12 @@ export const V2_0_EntityChange = defineVersionChange({ } } + if (input.flags) { + for (const [featureId, flag] of Object.entries(input.flags)) { + balancesV0[featureId] = flagV0ToBalanceV0({ input: flag }); + } + } + // Return V0 entity format (without purchases field) const { purchases: _purchases, ...rest } = input; diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index 8ea7cc262..49956b168 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -11,6 +11,7 @@ import { V1_2_TrialsUsedChange } from "@api/customers/components/apiTrialsUsed/c import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange"; import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_CustomerQueryChange"; +import { V2_0_CustomerQueryChange } from "@api/customers/requestChanges/V2.0_CustomerQueryChange"; // Import entity changes import { V1_2_EntityChange } from "@api/entities/changes/V1.2_EntityChange"; import { V2_0_EntityChange } from "@api/entities/changes/V2.0_EntityChange"; @@ -46,6 +47,7 @@ import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass"; export const V2_1_CHANGES: VersionChangeConstructor[] = [ 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_CustomerQueryChange, // Transforms Customer Query TO V2.1 format (adds flags.feature) 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 diff --git a/shared/utils/expandUtils.ts b/shared/utils/expandUtils.ts index edae55b6e..856b04ba6 100644 --- a/shared/utils/expandUtils.ts +++ b/shared/utils/expandUtils.ts @@ -60,15 +60,20 @@ export const scopeExpandForCtx = ({ prefix, }: { ctx: T; - prefix: string; + prefix: string | string[]; }): T => { + const prefixes = Array.isArray(prefix) ? prefix : [prefix]; + return { ...ctx, expand: ctx.expand.flatMap((entry) => { - if (entry === prefix) return [""]; - if (entry.startsWith(`${prefix}.`)) { - return [entry.slice(prefix.length + 1)]; + for (const currentPrefix of prefixes) { + if (entry === currentPrefix) return [""]; + if (entry.startsWith(`${currentPrefix}.`)) { + return [entry.slice(currentPrefix.length + 1)]; + } } + return []; }), };