add plan_id, reset to TrackDeduction; switch tinybird events.deductions to JSON

This commit is contained in:
Owen Greenhalgh
2026-05-13 11:23:59 +01:00
parent 6176216716
commit 314f64b2c8
8 changed files with 81 additions and 19 deletions

View File

@@ -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,
},
],

View File

@@ -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<string, string>();
const rolloverIdToFeatureId = new Map<string, string>();
const customerEntitlementById = new Map<string, FullCusEntWithFullCusProduct>();
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,
});
}

View File

@@ -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_<customerId>`),
// 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);
},
);

View File

@@ -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>): 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,
},
]);

View File

@@ -10,6 +10,8 @@ const buildDeductions = (): TrackDeduction[] => [
{
balance_id: "cus_ent_messages",
feature_id: "messages",
plan_id: null,
reset: null,
value: 4,
},
];

View File

@@ -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
defaultValueOfTypeName('JSON') AS deductions

View File

@@ -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).",

View File

@@ -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,
},
],