diff --git a/packages/openapi/v2.1/contracts/balancesContract.ts b/packages/openapi/v2.1/contracts/balancesContract.ts index 9c9dd96f0..059360f4c 100644 --- a/packages/openapi/v2.1/contracts/balancesContract.ts +++ b/packages/openapi/v2.1/contracts/balancesContract.ts @@ -116,6 +116,12 @@ export const balancesTrackContract = oc { balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", feature_id: "messages", + plan_id: "pro", + reset: { + interval: "month", + interval_count: undefined, + resets_at: 1781288736881, + }, value: 1, }, ], diff --git a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts index 1ff5c65c8..a9b206be5 100644 --- a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts +++ b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts @@ -1,4 +1,7 @@ import { + cusEntsToPlanId, + cusEntsToReset, + type FullCusEntWithFullCusProduct, type FullSubject, fullSubjectToCustomerEntitlements, type TrackDeduction, @@ -18,14 +21,16 @@ export const projectMutationLogsToTrackDeductionsV2 = ({ fullSubject, }); - const customerEntitlementIdToFeatureId = new Map(); - const rolloverIdToFeatureId = new Map(); + const customerEntitlementById = new Map(); + const rolloverIdToCustomerEntitlement = new Map< + string, + FullCusEntWithFullCusProduct + >(); for (const customerEntitlement of customerEntitlements) { - const featureId = customerEntitlement.entitlement.feature.id; - customerEntitlementIdToFeatureId.set(customerEntitlement.id, featureId); + customerEntitlementById.set(customerEntitlement.id, customerEntitlement); for (const rollover of customerEntitlement.rollovers ?? []) { - rolloverIdToFeatureId.set(rollover.id, featureId); + rolloverIdToCustomerEntitlement.set(rollover.id, customerEntitlement); } } @@ -38,7 +43,7 @@ export const projectMutationLogsToTrackDeductionsV2 = ({ if (log.balance_delta === 0) continue; let balanceId: string; - let featureId: string | undefined; + let customerEntitlement: FullCusEntWithFullCusProduct | undefined; let typeQualifier: string; if ( @@ -46,17 +51,17 @@ export const projectMutationLogsToTrackDeductionsV2 = ({ log.customer_entitlement_id ) { balanceId = log.customer_entitlement_id; - featureId = customerEntitlementIdToFeatureId.get(balanceId); + customerEntitlement = customerEntitlementById.get(balanceId); typeQualifier = "ce"; } else if (log.target_type === "rollover" && log.rollover_id) { balanceId = log.rollover_id; - featureId = rolloverIdToFeatureId.get(balanceId); + customerEntitlement = rolloverIdToCustomerEntitlement.get(balanceId); typeQualifier = "ro"; } else { continue; } - if (!featureId) continue; + if (!customerEntitlement) continue; const key = `${typeQualifier}::${balanceId}`; const existing = aggregated.get(key); @@ -71,7 +76,9 @@ export const projectMutationLogsToTrackDeductionsV2 = ({ aggregated.set(key, { balance_id: balanceId, - feature_id: featureId, + feature_id: customerEntitlement.entitlement.feature.id, + plan_id: cusEntsToPlanId({ cusEnts: [customerEntitlement] }), + reset: cusEntsToReset({ cusEnts: [customerEntitlement] }), value: valueDelta, }); } diff --git a/server/tests/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts index 3a5d0f43b..cdf3bd0b0 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -1,10 +1,11 @@ import { expect, test } from "bun:test"; -import type { - ApiCustomerV3, - ApiEventsListResponse, - TrackDeduction, - TrackResponseV3, +import { + type ApiCustomerV3, + type ApiEventsListResponse, + ResetInterval, + type TrackDeduction, + type TrackResponseV3, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; @@ -385,7 +386,16 @@ test.concurrent( expect(trackedEvent?.deductions).toBeDefined(); expect(trackedEvent?.deductions).not.toBeNull(); expect(trackedEvent?.deductions).toHaveLength(1); - expect(trackedEvent?.deductions?.[0].feature_id).toBe(TestFeature.Messages); - expect(trackedEvent?.deductions?.[0].value).toBe(12); + const deduction = trackedEvent?.deductions?.[0]; + expect(deduction?.feature_id).toBe(TestFeature.Messages); + expect(deduction?.value).toBe(12); + // plan_id traces back to the customer entitlement's product. The test + // framework namespaces product IDs per-customer (e.g. `free_`), + // so we assert the prefix rather than a bare "free" string. + expect(deduction?.plan_id).toBe(`free_${customerId}`); + // `reset` carries the entitlement interval. items.free() ships month-based + // entitlements, so the round-tripped reset should reflect that. + expect(deduction?.reset).not.toBeNull(); + expect(deduction?.reset?.interval).toBe(ResetInterval.Month); }, ); diff --git a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts index 74b5d56ff..7426e8def 100644 --- a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts +++ b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts @@ -4,6 +4,7 @@ import { type Feature, type FullCustomerEntitlement, type FullSubject, + ResetInterval, SubjectType, } from "@autumn/shared"; import { projectMutationLogsToTrackDeductionsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.js"; @@ -122,6 +123,17 @@ const buildLog = (overrides: Partial): MutationLogItem => ({ ...overrides, }); +// Cus-ents built by `buildCustomerEntitlement` flow through +// `fullSubjectToCustomerEntitlements` as `extra_customer_entitlements`, which +// stamps `customer_product: null` — so `cusEntsToPlanId` always returns null +// in this fixture. The entitlement interval is "month" with count 1, so +// `cusEntsToReset` returns this shape consistently. +const expectedReset = { + interval: ResetInterval.Month, + interval_count: undefined, + resets_at: null, +}; + describe("projectMutationLogsToTrackDeductionsV2", () => { test("returns an empty array when there are no logs", () => { const fullSubject = buildFullSubject({ customerEntitlements: [] }); @@ -154,6 +166,8 @@ describe("projectMutationLogsToTrackDeductionsV2", () => { { balance_id: "cus_ent_messages", feature_id: "messages", + plan_id: null, + reset: expectedReset, value: 4, }, ]); @@ -243,11 +257,15 @@ describe("projectMutationLogsToTrackDeductionsV2", () => { expect(result).toContainEqual({ balance_id: "cus_ent_messages", feature_id: "messages", + plan_id: null, + reset: expectedReset, value: 1, }); expect(result).toContainEqual({ balance_id: "cus_ent_ai_credits", feature_id: "ai_credits", + plan_id: null, + reset: expectedReset, value: 7, }); }); @@ -279,6 +297,8 @@ describe("projectMutationLogsToTrackDeductionsV2", () => { { balance_id: "roll_1", feature_id: "messages", + plan_id: null, + reset: expectedReset, value: 2, }, ]); @@ -359,6 +379,8 @@ describe("projectMutationLogsToTrackDeductionsV2", () => { { balance_id: "cus_ent_messages", feature_id: "messages", + plan_id: null, + reset: expectedReset, value: 5, }, ]); diff --git a/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts index f46eb29d8..52d8768a8 100644 --- a/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts +++ b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts @@ -10,6 +10,8 @@ const buildDeductions = (): TrackDeduction[] => [ { balance_id: "cus_ent_messages", feature_id: "messages", + plan_id: null, + reset: null, value: 4, }, ]; diff --git a/server/tinybird/datasources/events.datasource b/server/tinybird/datasources/events.datasource index ecefb94ed..c22849824 100644 --- a/server/tinybird/datasources/events.datasource +++ b/server/tinybird/datasources/events.datasource @@ -17,7 +17,7 @@ SCHEMA > `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, `customer_id` String `json:$.customer_id`, `properties` JSON `json:$.properties`, - `deductions` Nullable(String) `json:$.deductions` DEFAULT NULL + `deductions` JSON `json:$.deductions` ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" @@ -40,4 +40,4 @@ FORWARD_QUERY > internal_entity_id, customer_id, properties, - defaultValueOfTypeName('Nullable(String)') AS deductions \ No newline at end of file + defaultValueOfTypeName('JSON') AS deductions \ No newline at end of file diff --git a/shared/api/balances/track/trackResponseV3.ts b/shared/api/balances/track/trackResponseV3.ts index b32c54a2d..ab9208967 100644 --- a/shared/api/balances/track/trackResponseV3.ts +++ b/shared/api/balances/track/trackResponseV3.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ApiBalanceResetSchema } from "../../customers/cusFeatures/apiBalance.js"; import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; export const TrackDeductionSchema = z.object({ @@ -9,6 +10,14 @@ export const TrackDeductionSchema = z.object({ feature_id: z.string().meta({ description: "The feature this balance belongs to.", }), + plan_id: z.string().nullable().meta({ + description: + "ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple).", + }), + reset: ApiBalanceResetSchema.nullable().meta({ + description: + "Reset configuration for the balance this deduction came from, or null if the balance doesn't reset.", + }), value: z.number().meta({ description: "Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).", diff --git a/shared/api/events/list/eventsListResponse.ts b/shared/api/events/list/eventsListResponse.ts index a56174efc..cd66c7580 100644 --- a/shared/api/events/list/eventsListResponse.ts +++ b/shared/api/events/list/eventsListResponse.ts @@ -15,6 +15,12 @@ export const EVENTS_LIST_EXAMPLE = { { balance_id: "cus_ent_3DdSDtFBlvDbjyUuJeUIbQlyN12", feature_id: "credits", + plan_id: "pro", + reset: { + interval: "month", + interval_count: undefined, + resets_at: 1765958215459, + }, value: 30, }, ],