add plan_id, reset to TrackDeduction; switch tinybird events.deductions to JSON
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,8 @@ const buildDeductions = (): TrackDeduction[] => [
|
||||
{
|
||||
balance_id: "cus_ent_messages",
|
||||
feature_id: "messages",
|
||||
plan_id: null,
|
||||
reset: null,
|
||||
value: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
@@ -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).",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user