diff --git a/packages/scrawn/src/core/pricing/builders.ts b/packages/scrawn/src/core/pricing/builders.ts index 935e434..008995b 100644 --- a/packages/scrawn/src/core/pricing/builders.ts +++ b/packages/scrawn/src/core/pricing/builders.ts @@ -20,6 +20,7 @@ import type { OpExpr, PriceExpr, ExprInput, + TagName, } from "./types.js"; import { validateExpr } from "./validate.js"; @@ -38,16 +39,16 @@ function toExpr(input: ExprInput): PriceExpr { * Create a tag reference expression. * Tags are resolved to their cent values by the backend. * - * @param name - The name of the price tag (must be non-empty) + * @param name - The name of the price tag (must be ALL CAPS with underscores only, e.g., PREMIUM_CALL, FEE) * @returns A TagExpr referencing the named tag - * @throws Error if name is empty or whitespace-only + * @throws Error if name is empty, whitespace-only, or not ALL_CAPS format * * @example * ```typescript * const premiumTag = tag('PREMIUM_CALL'); * ``` */ -export function tag(name: string): TagExpr { +export function tag(name: TagName): TagExpr { const expr: TagExpr = { kind: "tag", name } as const; validateExpr(expr); // Will throw if invalid return expr; diff --git a/packages/scrawn/src/core/pricing/index.ts b/packages/scrawn/src/core/pricing/index.ts index 0ca524f..1b12241 100644 --- a/packages/scrawn/src/core/pricing/index.ts +++ b/packages/scrawn/src/core/pricing/index.ts @@ -30,6 +30,7 @@ export type { OpExpr, PriceExpr, ExprInput, + TagName, } from "./types.js"; // Export builder functions diff --git a/packages/scrawn/src/core/pricing/types.ts b/packages/scrawn/src/core/pricing/types.ts index 8d6e3db..879a1ac 100644 --- a/packages/scrawn/src/core/pricing/types.ts +++ b/packages/scrawn/src/core/pricing/types.ts @@ -19,6 +19,15 @@ */ export type OpType = "ADD" | "SUB" | "MUL" | "DIV"; +/** + * Intellisense hint type for tag names. + * Tag names must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE, INPUT_RATE). + * No lowercase, digits, or hyphens allowed. + * + * This is a branded type that provides IDE hints while remaining compatible with `string`. + */ +export type TagName = Uppercase & { readonly __brand?: "TagName" }; + /** * A literal amount in cents (must be an integer). */ @@ -29,10 +38,11 @@ export interface AmountExpr { /** * A reference to a named price tag (resolved by the backend). + * Tag names must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE). */ export interface TagExpr { readonly kind: "tag"; - readonly name: string; + readonly name: TagName; } /** diff --git a/packages/scrawn/src/core/pricing/validate.ts b/packages/scrawn/src/core/pricing/validate.ts index af7aa79..f7de099 100644 --- a/packages/scrawn/src/core/pricing/validate.ts +++ b/packages/scrawn/src/core/pricing/validate.ts @@ -11,6 +11,7 @@ * - Non-finite numbers (NaN, Infinity) * - Empty operation arguments (ops need at least 2 args) * - Empty/whitespace tag names + * - Tag name format (must be ALL_CAPS with underscores only) * * SDK does NOT validate: * - Tag existence (backend resolves tags) @@ -72,7 +73,8 @@ function validateAmount(value: number): void { /** * Validate a tag name. - * Must be a non-empty string with no leading/trailing whitespace. + * Must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE, INPUT_RATE). + * No lowercase, digits, or hyphens allowed. */ function validateTagName(name: string): void { if (typeof name !== "string") { @@ -91,11 +93,11 @@ function validateTagName(name: string): void { if (name.trim().length === 0) { throw new PricingExpressionError("Tag name cannot be only whitespace"); } - // Validate tag name format: alphanumeric, underscores, hyphens - if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) { + // Validate tag name format: ALL CAPS with underscores only + if (!/^[A-Z_]+$/.test(name)) { throw new PricingExpressionError( - `Tag name must start with a letter or underscore and contain only ` + - `alphanumeric characters, underscores, or hyphens: "${name}"` + `Tag name must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE). ` + + `No lowercase, digits, or hyphens allowed. Got: "${name}"` ); } } diff --git a/packages/scrawn/src/core/types/event.ts b/packages/scrawn/src/core/types/event.ts index 103c92b..9562337 100644 --- a/packages/scrawn/src/core/types/event.ts +++ b/packages/scrawn/src/core/types/event.ts @@ -25,6 +25,12 @@ const PriceExprSchema = z.custom( } ); +/** + * Regex for validating tag names: ALL CAPS with underscores only. + * No lowercase, digits, or hyphens allowed. + */ +const TAG_NAME_REGEX = /^[A-Z_]+$/; + /** * Zod schema for event payload validation. * @@ -44,6 +50,10 @@ export const EventPayloadSchema = z debitTag: z .string() .min(1, "debitTag must be a non-empty string") + .regex( + TAG_NAME_REGEX, + "debitTag must be ALL CAPS with underscores only (e.g., PREMIUM_CALL, FEE). No lowercase, digits, or hyphens allowed." + ) .optional(), debitExpr: PriceExprSchema.optional(), }) @@ -219,11 +229,19 @@ export interface MiddlewareEventConfig { * * Represents a direct amount, a named price tag, or a pricing expression for billing. * Exactly one of amount, tag, or expr must be provided. + * Tag names must be ALL CAPS with underscores only (e.g., CLAUDE_INPUT, GPT4_OUTPUT_RATE). */ const DebitFieldSchema = z .object({ amount: z.number().nonnegative("amount must be non-negative").optional(), - tag: z.string().min(1, "tag must be a non-empty string").optional(), + tag: z + .string() + .min(1, "tag must be a non-empty string") + .regex( + TAG_NAME_REGEX, + "tag must be ALL CAPS with underscores only (e.g., CLAUDE_INPUT, FEE). No lowercase, digits, or hyphens allowed." + ) + .optional(), expr: PriceExprSchema.optional(), }) .refine( @@ -309,8 +327,8 @@ export const AITokenUsagePayloadSchema = z.object({ * model: 'gpt-4', * inputTokens: 100, * outputTokens: 50, - * inputDebit: { expr: mul(tag('GPT4_INPUT_RATE'), 100) }, // rate * tokens - * outputDebit: { expr: mul(tag('GPT4_OUTPUT_RATE'), 50) } // rate * tokens + * inputDebit: { expr: mul(tag('GPT_INPUT_RATE'), 100) }, // rate * tokens + * outputDebit: { expr: mul(tag('GPT_OUTPUT_RATE'), 50) } // rate * tokens * }; * ``` */ diff --git a/packages/scrawn/tests/unit/pricing/pricing.test.ts b/packages/scrawn/tests/unit/pricing/pricing.test.ts index 29182e4..77b3baf 100644 --- a/packages/scrawn/tests/unit/pricing/pricing.test.ts +++ b/packages/scrawn/tests/unit/pricing/pricing.test.ts @@ -21,16 +21,14 @@ describe("Pricing DSL Builders", () => { expect(expr).toEqual({ kind: "tag", name: "PREMIUM_CALL" }); }); - it("accepts underscores and hyphens", () => { + it("accepts ALL_CAPS names with underscores", () => { expect(tag("PREMIUM_CALL")).toEqual({ kind: "tag", name: "PREMIUM_CALL", }); - expect(tag("premium-call")).toEqual({ - kind: "tag", - name: "premium-call", - }); - expect(tag("_private")).toEqual({ kind: "tag", name: "_private" }); + expect(tag("FEE")).toEqual({ kind: "tag", name: "FEE" }); + expect(tag("_PRIVATE")).toEqual({ kind: "tag", name: "_PRIVATE" }); + expect(tag("__DOUBLE")).toEqual({ kind: "tag", name: "__DOUBLE" }); }); it("throws on empty tag name", () => { @@ -55,6 +53,31 @@ describe("Pricing DSL Builders", () => { expect(() => tag("has spaces")).toThrow(PricingExpressionError); expect(() => tag("has.dots")).toThrow(PricingExpressionError); }); + + it("throws on lowercase tag names", () => { + expect(() => tag("premium")).toThrow(PricingExpressionError); + expect(() => tag("premium")).toThrow("ALL CAPS"); + expect(() => tag("premium_call")).toThrow(PricingExpressionError); + }); + + it("throws on mixed case tag names", () => { + expect(() => tag("Premium")).toThrow(PricingExpressionError); + expect(() => tag("Premium")).toThrow("ALL CAPS"); + expect(() => tag("PremiumCall")).toThrow(PricingExpressionError); + }); + + it("throws on tag names with digits", () => { + expect(() => tag("GPT4")).toThrow(PricingExpressionError); + expect(() => tag("GPT4")).toThrow("ALL CAPS"); + expect(() => tag("RATE2")).toThrow(PricingExpressionError); + expect(() => tag("123")).toThrow(PricingExpressionError); + }); + + it("throws on tag names with hyphens", () => { + expect(() => tag("PREMIUM-CALL")).toThrow(PricingExpressionError); + expect(() => tag("PREMIUM-CALL")).toThrow("ALL CAPS"); + expect(() => tag("my-tag")).toThrow(PricingExpressionError); + }); }); describe("amount()", () => { diff --git a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts index 8fd1802..7b73990 100644 --- a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts +++ b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts @@ -36,8 +36,8 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: mul(tag("GPT4_INPUT_RATE"), 100) }, - outputDebit: { expr: mul(tag("GPT4_OUTPUT_RATE"), 50) }, + inputDebit: { expr: mul(tag("GPT_INPUT_RATE"), 100) }, + outputDebit: { expr: mul(tag("GPT_OUTPUT_RATE"), 50) }, }); expect(result.success).toBe(true); @@ -315,5 +315,67 @@ describe("AITokenUsagePayloadSchema", () => { expect(result.success).toBe(false); }); + + describe("tag format validation", () => { + it("rejects lowercase tag in inputDebit", () => { + const result = AITokenUsagePayloadSchema.safeParse({ + userId: "user_1", + model: "gpt-4", + inputTokens: 100, + outputTokens: 50, + inputDebit: { tag: "claude_input" }, + outputDebit: { tag: "CLAUDE_OUTPUT" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects lowercase tag in outputDebit", () => { + const result = AITokenUsagePayloadSchema.safeParse({ + userId: "user_1", + model: "gpt-4", + inputTokens: 100, + outputTokens: 50, + inputDebit: { tag: "CLAUDE_INPUT" }, + outputDebit: { tag: "claude_output" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects tag with digits", () => { + const result = AITokenUsagePayloadSchema.safeParse({ + userId: "user_1", + model: "gpt-4", + inputTokens: 100, + outputTokens: 50, + inputDebit: { tag: "GPT4_INPUT" }, + outputDebit: { tag: "CLAUDE_OUTPUT" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects tag with hyphens", () => { + const result = AITokenUsagePayloadSchema.safeParse({ + userId: "user_1", + model: "gpt-4", + inputTokens: 100, + outputTokens: 50, + inputDebit: { tag: "CLAUDE-INPUT" }, + outputDebit: { tag: "CLAUDE_OUTPUT" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects mixed case tag", () => { + const result = AITokenUsagePayloadSchema.safeParse({ + userId: "user_1", + model: "gpt-4", + inputTokens: 100, + outputTokens: 50, + inputDebit: { tag: "Claude_Input" }, + outputDebit: { tag: "CLAUDE_OUTPUT" }, + }); + expect(result.success).toBe(false); + }); + }); }); }); diff --git a/packages/scrawn/tests/unit/types/eventPayload.test.ts b/packages/scrawn/tests/unit/types/eventPayload.test.ts index 808a96f..906dae7 100644 --- a/packages/scrawn/tests/unit/types/eventPayload.test.ts +++ b/packages/scrawn/tests/unit/types/eventPayload.test.ts @@ -115,4 +115,54 @@ describe("EventPayloadSchema", () => { expect(result.success).toBe(false); }); + + describe("debitTag format validation", () => { + it("accepts ALL_CAPS debitTag", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "PREMIUM_FEATURE", + }); + expect(result.success).toBe(true); + }); + + it("rejects lowercase debitTag", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "premium", + }); + expect(result.success).toBe(false); + }); + + it("rejects mixed case debitTag", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "Premium_Feature", + }); + expect(result.success).toBe(false); + }); + + it("rejects debitTag with digits", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "GPT4_CALL", + }); + expect(result.success).toBe(false); + }); + + it("rejects debitTag with hyphens", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "PREMIUM-CALL", + }); + expect(result.success).toBe(false); + }); + + it("rejects debitTag with special characters", () => { + const result = EventPayloadSchema.safeParse({ + userId: "user_1", + debitTag: "PREMIUM.CALL", + }); + expect(result.success).toBe(false); + }); + }); });