diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index ddb90b7dc..a2904d13f 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -13,6 +13,7 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_V2 \ 'integration/balances/check' \ + 'integration/balances/track' \ 'balances/track/basic' \ 'balances/track/concurrency' \ 'balances/track/breakdown' \ diff --git a/server/src/honoMiddlewares/idempotencyMiddleware.ts b/server/src/honoMiddlewares/idempotencyMiddleware.ts index f7cbea336..baa9cbf54 100644 --- a/server/src/honoMiddlewares/idempotencyMiddleware.ts +++ b/server/src/honoMiddlewares/idempotencyMiddleware.ts @@ -1,8 +1,6 @@ -import { ErrCode, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; -import { redis } from "@/external/redis/initRedis.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils"; +import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js"; /** * Middleware that checks for idempotence in a request @@ -17,19 +15,11 @@ export const idempotencyMiddleware = async ( headers["idempotency-key"] || headers["Idempotency-Key"]; if (idempotencyKey) { - const redisKey = `${ctx.org.id}:${ctx.env}:idempotency:${idempotencyKey}`; - // Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions - const wasSet = await tryRedisWrite(() => { - return redis.set(redisKey, "1", "PX", 1000 * 60 * 60 * 24, "NX"); // 24 hours, only set if not exists + await checkIdempotencyKey({ + orgId: ctx.org.id, + env: ctx.env, + idempotencyKey, }); - - if (!wasSet) { - throw new RecaseError({ - message: `Another request with idempotency key ${idempotencyKey} has already been received`, - code: ErrCode.DuplicateIdempotencyKey, - statusCode: 409, - }); - } } await next(); diff --git a/server/src/internal/balances/handlers/handleTrack.ts b/server/src/internal/balances/handlers/handleTrack.ts index 3a92e54b1..ae364db62 100644 --- a/server/src/internal/balances/handlers/handleTrack.ts +++ b/server/src/internal/balances/handlers/handleTrack.ts @@ -1,4 +1,10 @@ -import { TrackParamsSchema, TrackQuerySchema } from "@autumn/shared"; +import { + AffectedResource, + ApiVersion, + TrackParamsSchema, + TrackParamsV0Schema, + TrackQuerySchema, +} from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { runTrackV2 } from "@/internal/balances/track/runTrackV2.js"; import { @@ -8,19 +14,15 @@ import { export const handleTrack = createRoute({ query: TrackQuerySchema, - body: TrackParamsSchema, + versionedBody: { + latest: TrackParamsSchema, + [ApiVersion.V1_Beta]: TrackParamsV0Schema, + }, + resource: AffectedResource.Track, handler: async (c) => { const body = c.req.valid("json"); const ctx = c.get("ctx"); - // Legacy: support value in properties - if (body.properties?.value) { - const parsedValue = Number(body.properties.value); - if (!Number.isNaN(parsedValue)) { - body.value = parsedValue; - } - } - // Build feature deductions const featureDeductions = body.feature_id ? getTrackFeatureDeductions({ diff --git a/server/src/internal/balances/track/runTrackV2.ts b/server/src/internal/balances/track/runTrackV2.ts index ea607e8e3..2eccaa150 100644 --- a/server/src/internal/balances/track/runTrackV2.ts +++ b/server/src/internal/balances/track/runTrackV2.ts @@ -47,7 +47,6 @@ export const runTrackV2 = async ({ await handleEventIdempotencyKey({ ctx, body, - fullCustomer, }); } diff --git a/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts b/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts index e1179c330..2004f1de9 100644 --- a/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts +++ b/server/src/internal/balances/track/utils/handleEventIdempotencyKey.ts @@ -1,32 +1,35 @@ -import type { FullCustomer, TrackParams } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { EventService } from "../../../api/events/EventService"; -import { buildEventInfo, initEvent } from "../../events/initEvent"; +import type { TrackParams } from "@autumn/shared"; +import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; export const handleEventIdempotencyKey = async ({ ctx, body, - fullCustomer, }: { ctx: AutumnContext; body: TrackParams; - fullCustomer: FullCustomer; }) => { - const eventInfo = buildEventInfo(body); - - const newEvent = initEvent({ - ctx, - eventInfo, - internalCustomerId: fullCustomer.internal_id, - internalEntityId: fullCustomer.entity?.internal_id ?? undefined, - customerId: body.customer_id, - entityId: body.entity_id, + await checkIdempotencyKey({ + orgId: ctx.org.id, + env: ctx.env, + idempotencyKey: `track:${body.idempotency_key}`, }); - await EventService.insert({ - db: ctx.db, - event: newEvent, - }); + // const eventInfo = buildEventInfo(body); - body.skip_event = true; + // const newEvent = initEvent({ + // ctx, + // eventInfo, + // internalCustomerId: fullCustomer.internal_id, + // internalEntityId: fullCustomer.entity?.internal_id ?? undefined, + // customerId: body.customer_id, + // entityId: body.entity_id, + // }); + + // await EventService.insert({ + // db: ctx.db, + // event: newEvent, + // }); + + // body.skip_event = true; }; diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index 12005c626..13f850fa6 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -53,7 +53,7 @@ const queueEvent = ({ body: TrackParams; fullCustomer: FullCustomer; }): void => { - if (body.skip_event || body.idempotency_key) return; + if (body.skip_event) return; const eventInfo = buildEventInfo(body); diff --git a/server/src/internal/misc/idempotency/checkIdempotencyKey.ts b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts new file mode 100644 index 000000000..7769ea2ee --- /dev/null +++ b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts @@ -0,0 +1,52 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import { redis } from "@/external/redis/initRedis.js"; + +const IDEMPOTENCY_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +/** + * Checks and sets an idempotency key in Redis using atomic SET NX operation. + * If Redis is not ready, allows the request to proceed (fail-open). + * Throws if the key already exists (duplicate request). + */ +export const checkIdempotencyKey = async ({ + orgId, + env, + idempotencyKey, +}: { + orgId: string; + env: string; + idempotencyKey: string; +}): Promise => { + // Fail-open: if Redis is not ready, allow the request + if (redis.status !== "ready") { + return; + } + + const redisKey = `${orgId}:${env}:idempotency:${idempotencyKey}`; + + try { + // Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions + const wasSet = await redis.set( + redisKey, + "1", + "PX", + IDEMPOTENCY_TTL_MS, + "NX", + ); + + if (!wasSet) { + throw new RecaseError({ + message: `Another request with idempotency key ${idempotencyKey} has already been received`, + code: ErrCode.DuplicateIdempotencyKey, + statusCode: 409, + }); + } + } catch (error) { + // Re-throw RecaseError (duplicate key) + if (error instanceof RecaseError) { + throw error; + } + // For other Redis errors, fail-open (allow request) + return; + } +}; diff --git a/server/tests/balances/track/basic/track-basic6.test.ts b/server/tests/balances/track/basic/track-basic6.test.ts index ca300bfea..25852cd0c 100644 --- a/server/tests/balances/track/basic/track-basic6.test.ts +++ b/server/tests/balances/track/basic/track-basic6.test.ts @@ -26,9 +26,14 @@ const freeProd = constructProduct({ const testCase = "track-basic6"; +// Generate unique idempotency keys per test run to avoid Redis TTL conflicts +const testRunId = Date.now().toString(36); + describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents duplicate tracks")}`, () => { const customerId = "track-basic6"; const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const idempotencyKey1 = `test-idempotency-key-1-${testRunId}`; + const idempotencyKey2 = `test-idempotency-key-2-${testRunId}`; beforeAll(async () => { await initCustomerV3({ @@ -58,13 +63,12 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl test("should process first track with idempotency key", async () => { const deductValue = 25.5; - const idempotencyKey = "test-idempotency-key-1"; await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: deductValue, - idempotency_key: idempotencyKey, + idempotency_key: idempotencyKey1, }); const customer = await autumnV1.customers.get(customerId); @@ -76,28 +80,28 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl expect(balance).toBe(expectedBalance); expect(usage).toBe(deductValue); + await timeout(2000); const eventsList = await getCustomerEvents({ customerId }); expect(eventsList).toHaveLength(1); - expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey); + expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1); expect(eventsList?.[0].value).toBe(deductValue); }); test("should reject second track with same idempotency key", async () => { const deductValue = 30.75; // Different value - const idempotencyKey = "test-idempotency-key-1"; // Same key // Get balance before attempting duplicate track const customerBefore = await autumnV1.customers.get(customerId); const balanceBefore = customerBefore.features[TestFeature.Messages].balance; // This should fail or be rejected due to duplicate idempotency key await expectAutumnError({ - errCode: ErrCode.DuplicateEvent, + errCode: ErrCode.DuplicateIdempotencyKey, func: async () => { await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: deductValue, - idempotency_key: idempotencyKey, + idempotency_key: idempotencyKey1, // Same key as first test }); }, }); @@ -117,12 +121,11 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl const eventsList = await getCustomerEvents({ customerId }); expect(eventsList).toHaveLength(1); - expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey); + expect(eventsList?.[0].idempotency_key).toBe(idempotencyKey1); }); test("should process track with different idempotency key", async () => { const deductValue = 15.25; - const idempotencyKey = "test-idempotency-key-2"; // Different key const customerBefore = await autumnV1.customers.get(customerId); const balanceBefore = customerBefore.features[TestFeature.Messages].balance; @@ -131,7 +134,7 @@ describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents dupl customer_id: customerId, feature_id: TestFeature.Messages, value: deductValue, - idempotency_key: idempotencyKey, + idempotency_key: idempotencyKey2, // Different key }); const customer = await autumnV1.customers.get(customerId); diff --git a/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts index cf255e920..47f905f63 100644 --- a/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts @@ -24,7 +24,7 @@ test( items: [allocatedUsersItem, priceItem], }); - const uniqueId = `paid-alloc-lock-${Date.now()}`; + const uniqueId = `paid-alloc-lock`; const { customerId, autumnV2 } = await initScenario({ customerId: uniqueId, setup: [ diff --git a/server/tests/integration/balances/track/track-misc.test.ts b/server/tests/integration/balances/track/track-misc.test.ts new file mode 100644 index 000000000..f553ebe6b --- /dev/null +++ b/server/tests/integration/balances/track/track-misc.test.ts @@ -0,0 +1,330 @@ +import { expect, test } from "bun:test"; + +import { + type ApiCustomerV3, + type ApiEntityV0, + CusExpand, + type TrackResponseV2, +} 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 { timeout } from "@tests/utils/genUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { EventService } from "@/internal/api/events/EventService.js"; + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC1: Auto-create customer and entity via track +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc1: track auto-creates customer and entity")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const customerId = "track-misc1"; + + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ customerId: "track-misc1" }), + s.products({ list: [freeProd], prefix: customerId }), + ], + actions: [], + }); + + const entityId = `${customerId}-entity-1`; + + await autumnV1.track({ + customer_id: customerId, + customer_data: { + name: "Test Customer", + email: "test@test.com", + }, + feature_id: TestFeature.Messages, + entity_id: entityId, + entity_data: { + name: "Test Entity", + feature_id: TestFeature.Users, + }, + value: 5, + }); + + // Verify customer was created with provided data + const customer = await autumnV1.customers.get(customerId); + expect(customer).toMatchObject({ + id: customerId, + name: "Test Customer", + email: "test@test.com", + }); + + // Verify entity was created with provided data + const entity = await autumnV1.entities.get(customerId, entityId); + expect(entity).toMatchObject({ + id: entityId, + name: "Test Entity", + }); + + // Verify customer.entities includes the created entity + const customerWithEntities = await autumnV1.customers.get( + customerId, + { expand: [CusExpand.Entities] }, + ); + expect(customerWithEntities.entities).toBeDefined(); + expect(customerWithEntities.entities).toHaveLength(1); + expect(customerWithEntities.entities?.[0].id).toBe(entityId); + expect(customerWithEntities.entities?.[0].name).toBe("Test Entity"); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC2: Track event stores properties +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc2: track event stores custom properties")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc2", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV1.track({ + customer_id: customerId, + customer_data: { + name: "track-misc2", + email: "track-misc2@test.com", + }, + feature_id: TestFeature.Messages, + value: 5, + properties: { + hello: "world", + foo: "bar", + }, + }); + + const customer = await autumnV1.customers.get(customerId, { + with_autumn_id: true, + }); + + await timeout(2000); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id!, + env: ctx.env, + }); + + expect(events).toHaveLength(1); + expect(events?.[0].properties).toMatchObject({ + hello: "world", + foo: "bar", + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC3: Track creates events when balance is empty +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc3: track creates events when customer has no balance")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc3", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const trackCount = Math.floor(Math.random() * 10) + 1; + let totalValue = 0; + + await Promise.all( + Array.from({ length: trackCount }, () => { + const trackValue = Math.random() * 10; + totalValue += trackValue; + return autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: trackValue, + }); + }), + ); + + const customer = await autumnV1.customers.get(customerId, { + with_autumn_id: true, + }); + + await timeout(2000); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id ?? "", + env: ctx.env, + }); + + expect(events).toHaveLength(trackCount); + expect(events.reduce((acc, event) => acc + (event.value ?? 0), 0)).toBe( + totalValue, + ); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC4: Track v1.2 response format +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc4: track returns correct v1.2 response format")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc4", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV2 = await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + expect(trackRes).toMatchObject({ + id: "placeholder", + code: "event_received", + customer_id: customerId, + feature_id: TestFeature.Messages, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC5: V1.2 properties.value maps to value field +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc5: V1.2 properties.value maps to value field")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc5", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Track using V1.2 legacy format with properties.value + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + properties: { + value: 42.1532, + }, + }); + + const customer = await autumnV1.customers.get(customerId, { + with_autumn_id: true, + }); + + // Verify balance was deducted correctly + expect(customer.features[TestFeature.Messages].balance).toBe( + new Decimal(100).sub(42.1532).toNumber(), + ); + expect(customer.features[TestFeature.Messages].usage).toBe(42.1532); + + await timeout(2000); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id!, + env: ctx.env, + }); + + expect(events).toHaveLength(1); + expect(events[0].value).toBe(42.1532); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC7: Track defaults to value: 1 when no value provided +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc7: track defaults to value 1 when no value provided")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc7", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Track without providing any value + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const customer = await autumnV1.customers.get(customerId, { + with_autumn_id: true, + }); + + await timeout(2000); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id!, + env: ctx.env, + }); + + expect(events).toHaveLength(1); + expect(events[0].value).toBe(1); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-MISC8: V1.2 properties.value is removed from properties after extraction +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("track-misc8: V1.2 properties.value is removed from stored properties")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "track-misc8", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Track using V1.2 legacy format with properties.value and other properties + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + properties: { + value: 25, + hello: "world", + foo: "bar", + }, + }); + + const customer = await autumnV1.customers.get(customerId, { + with_autumn_id: true, + }); + + await timeout(2000); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id!, + env: ctx.env, + }); + + expect(events).toHaveLength(1); + expect(events[0].value).toBe(25); + // Verify value was removed from properties but other props remain + expect(events[0].properties).toMatchObject({ + hello: "world", + foo: "bar", + }); + expect(events[0].properties).not.toHaveProperty("value"); +}); diff --git a/shared/api/balances/track/prevVersions/trackParamsV0.ts b/shared/api/balances/track/prevVersions/trackParamsV0.ts new file mode 100644 index 000000000..bdc571e62 --- /dev/null +++ b/shared/api/balances/track/prevVersions/trackParamsV0.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; +import { CustomerDataSchema } from "../../../common/customerData.js"; +import { EntityDataSchema } from "../../../common/entityData.js"; + +/** + * TrackParamsV0Schema - V1.2 and earlier format + * + * In V1.2, the `value` field could be passed either as a top-level field OR + * inside `properties.value`. This schema supports both, with the transformation + * extracting `properties.value` only if top-level `value` is not provided. + */ +export const TrackParamsV0Schema = z + .object({ + customer_id: z.string().nonempty(), + feature_id: z.string().optional(), + event_name: z.string().nonempty().optional(), + value: z.number().optional(), + properties: z.record(z.string(), z.any()).optional(), + timestamp: z.number().optional(), + idempotency_key: z.string().optional(), + customer_data: CustomerDataSchema.optional(), + entity_id: z.string().optional(), + entity_data: EntityDataSchema.optional(), + overage_behavior: z.enum(["cap", "reject"]).optional(), + skip_event: z.boolean().optional(), + }) + .refine( + (data) => { + if (data.feature_id && data.event_name) return false; + if (!data.feature_id && !data.event_name) return false; + return true; + }, + { + message: "Either feature_id or event_name must be provided", + }, + ); + +export type TrackParamsV0 = z.infer; diff --git a/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts b/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts new file mode 100644 index 000000000..50027b2bc --- /dev/null +++ b/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts @@ -0,0 +1,72 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { TrackParamsV0Schema } from "../prevVersions/trackParamsV0.js"; +import { TrackParamsSchema } from "../trackParams.js"; + +/** + * V1_2_TrackParamsChange: Transforms track request body TO latest format + * + * Applied when: sourceVersion <= V1.2 + * + * Breaking changes introduced in V2.0 (that we transform here): + * + * 1. Value field location: + * - V1.2: `properties.value` (value passed inside properties object) + * - V2.0+: `value` (top-level field) + * + * This transformation extracts `properties.value` and maps it to the + * top-level `value` field for V1.2 clients, ensuring they can continue + * using the legacy format. + * + * Input: TrackParamsV0 (V1.2 format with properties.value) + * Output: TrackParamsV1 (V2.0+ format with top-level value) + */ +export const V1_2_TrackParamsChange = defineVersionChange({ + name: "V1_2 Track Params Change", + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Maps properties.value to top-level value field for V1.2 clients", + ], + affectedResources: [AffectedResource.Track], + newSchema: TrackParamsSchema, + oldSchema: TrackParamsV0Schema, + + affectsRequest: true, + affectsResponse: false, + + // Request: V1.2 → V2.0 (extract properties.value to value if not already set) + transformRequest: ({ + input, + }: { + input: z.infer; + }): z.infer => { + // Keep original value if provided, otherwise extract from properties.value + let value = input.value; + let properties = input.properties; + + if (input.properties?.value !== undefined) { + // Only use properties.value if top-level value is not set + if (value === undefined) { + const parsedValue = Number(input.properties.value); + if (!Number.isNaN(parsedValue)) { + value = parsedValue; + } + } + + // Always remove value from properties after processing + const { value: _, ...restProperties } = input.properties; + properties = Object.keys(restProperties).length > 0 ? restProperties : {}; + } + + return { + ...input, + properties, + value, + }; + }, +}); diff --git a/shared/api/models.ts b/shared/api/models.ts index cc6fc12f8..df36ed168 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -66,6 +66,7 @@ export * from "./balances/check/prevVersions/CheckResponseV0.js"; export * from "./balances/check/prevVersions/CheckResponseV1.js"; export * from "./balances/create/createBalanceParams.js"; export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; +export * from "./balances/track/prevVersions/trackParamsV0.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; export * from "./balances/track/trackResponseV2.js"; diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index f44ba2e6f..3cdaa0717 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -25,6 +25,7 @@ import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange. 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 { V1_2_TrackChange } from "../../balances/track/changes/V1.2_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"; import { ApiVersion } from "../ApiVersion.js"; @@ -42,6 +43,7 @@ export const V2_CHANGES: VersionChangeConstructor[] = [ V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options) V1_2_TrackChange, // Transforms Track TO V1.2 format from V0.2 format + V1_2_TrackParamsChange, // Transforms Track params TO V2.0 (maps properties.value → value) V1_2_FeatureChange, // Transforms Feature TO V1_Beta format (V0) from V2 format (V1) V1_2_CreateFeatureChange, // Transforms Create Feature params TO V1_Beta