From ab97940d163c775d143ee7dbdb05f7580bd5594c Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 14 Jan 2026 10:47:00 -0500 Subject: [PATCH 1/4] feat(products): change prices array to single price object Align with Polar model: one product = one price. METERED prices are filtered at API level, so SDK consumers only see the base price. - ProductSchema.price is now singular (nullable) - CheckoutProductSchema.price is now singular (nullable) - Added currency field to price objects --- src/contracts/products.ts | 3 ++- src/schemas/product.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/contracts/products.ts b/src/contracts/products.ts index 1682354..d1afb05 100644 --- a/src/contracts/products.ts +++ b/src/contracts/products.ts @@ -5,6 +5,7 @@ export const ProductPriceSchema = z.object({ id: z.string(), amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), priceAmount: z.number().nullable(), + currency: z.string(), }); export const ProductSchema = z.object({ @@ -12,7 +13,7 @@ export const ProductSchema = z.object({ name: z.string(), description: z.string().nullable(), recurringInterval: z.enum(["MONTH", "QUARTER", "YEAR"]).nullable(), - prices: z.array(ProductPriceSchema), + price: ProductPriceSchema.nullable(), }); export const ListProductsOutputSchema = z.object({ diff --git a/src/schemas/product.ts b/src/schemas/product.ts index b25984b..3ed86bf 100644 --- a/src/schemas/product.ts +++ b/src/schemas/product.ts @@ -4,6 +4,7 @@ export const CheckoutProductPriceSchema = z.object({ id: z.string(), amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), priceAmount: z.number().nullable(), + currency: z.string(), }); export const CheckoutProductSchema = z.object({ @@ -11,5 +12,5 @@ export const CheckoutProductSchema = z.object({ name: z.string(), description: z.string().nullable(), recurringInterval: z.enum(["MONTH", "QUARTER", "YEAR"]).nullable(), - prices: z.array(CheckoutProductPriceSchema), + price: CheckoutProductPriceSchema.nullable(), }); From 362fd1c03cccbca50df4ef583618ec1cb5efdbe0 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 14 Jan 2026 10:59:51 -0500 Subject: [PATCH 2/4] test: update product schema fixtures --- tests/index.test.ts | 13 ++--- tests/schemas/checkout.test.ts | 13 ++--- tests/schemas/product.test.ts | 94 ++++++++++++++++++---------------- 3 files changed, 57 insertions(+), 63 deletions(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index 41485b5..8b67202 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -53,17 +53,12 @@ describe('API Contract Index', () => { name: 'Test Product', description: null, recurringInterval: null, - prices: [{ + price: { id: 'price_123', amountType: 'FIXED' as const, priceAmount: 1000, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, - }], + currency: 'USD', + }, }], providedAmount: null, totalAmount: null, @@ -196,4 +191,4 @@ describe('API Contract Index', () => { expect(contract.checkout.paymentReceived).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/schemas/checkout.test.ts b/tests/schemas/checkout.test.ts index c525c0f..876743b 100644 --- a/tests/schemas/checkout.test.ts +++ b/tests/schemas/checkout.test.ts @@ -39,17 +39,12 @@ const mockProduct = { name: 'Test Product', description: 'A test product', recurringInterval: null, - prices: [{ + price: { id: 'price_123', amountType: 'FIXED' as const, priceAmount: 1000, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, - }], + currency: 'USD', + }, }; const mockInvoice = { @@ -388,4 +383,4 @@ describe('CheckoutSchema', () => { expect(result.success).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/tests/schemas/product.test.ts b/tests/schemas/product.test.ts index 4cae177..b6eac75 100644 --- a/tests/schemas/product.test.ts +++ b/tests/schemas/product.test.ts @@ -8,6 +8,7 @@ const baseProductPriceData = { id: "price_123", amountType: "FIXED" as const, priceAmount: null, + currency: "USD", }; const baseProductData = { @@ -15,7 +16,7 @@ const baseProductData = { name: "Test Product", description: null, recurringInterval: null, - prices: [baseProductPriceData], + price: baseProductPriceData, }; describe("Product Schemas", () => { @@ -144,24 +145,18 @@ describe("Product Schemas", () => { }); }); - test("should validate product with multiple prices", () => { - const productWithMultiplePrices = { + test("should validate product with a custom price", () => { + const productWithCustomPrice = { ...baseProductData, - prices: [ - { - id: "price_1", - amountType: "FIXED" as const, - priceAmount: 999, - }, - { - id: "price_2", - amountType: "CUSTOM" as const, - priceAmount: null, - }, - ], + price: { + ...baseProductPriceData, + id: "price_2", + amountType: "CUSTOM" as const, + priceAmount: null, + }, }; - const result = CheckoutProductSchema.safeParse(productWithMultiplePrices); + const result = CheckoutProductSchema.safeParse(productWithCustomPrice); expect(result.success).toBe(true); }); @@ -185,23 +180,23 @@ describe("Product Schemas", () => { expect(result.success).toBe(false); }); - test("should reject product without prices array", () => { - const productWithoutPrices = { + test("should reject product without price field", () => { + const productWithoutPrice = { ...baseProductData, - prices: undefined, + price: undefined, }; - const result = CheckoutProductSchema.safeParse(productWithoutPrices); + const result = CheckoutProductSchema.safeParse(productWithoutPrice); expect(result.success).toBe(false); }); - test("should allow product with empty prices array", () => { - const productWithEmptyPrices = { + test("should allow product with null price", () => { + const productWithNullPrice = { ...baseProductData, - prices: [], + price: null, }; - const result = CheckoutProductSchema.safeParse(productWithEmptyPrices); + const result = CheckoutProductSchema.safeParse(productWithNullPrice); expect(result.success).toBe(true); }); @@ -215,15 +210,13 @@ describe("Product Schemas", () => { expect(result.success).toBe(false); }); - test("should reject product with invalid price in prices array", () => { + test("should reject product with invalid price object", () => { const productWithInvalidPrice = { ...baseProductData, - prices: [ - { - ...baseProductPriceData, - amountType: "INVALID_TYPE" as any, - }, - ], + price: { + ...baseProductPriceData, + amountType: "INVALID_TYPE" as any, + }, }; const result = CheckoutProductSchema.safeParse(productWithInvalidPrice); @@ -252,33 +245,44 @@ describe("Product Schemas", () => { }); describe("Integration scenarios", () => { - test("should validate complete product with all supported price types", () => { - const completeProduct = { - id: "product_complete", - name: "Complete Product", - description: "A product with all types of prices", - recurringInterval: "MONTH" as const, - prices: [ - { + test("should validate products with all supported price types", () => { + const products = [ + { + ...baseProductData, + id: "product_fixed", + price: { + ...baseProductPriceData, id: "price_fixed", amountType: "FIXED" as const, priceAmount: 2999, }, - { + }, + { + ...baseProductData, + id: "product_custom", + price: { + ...baseProductPriceData, id: "price_custom", amountType: "CUSTOM" as const, priceAmount: null, }, - { + }, + { + ...baseProductData, + id: "product_free", + price: { + ...baseProductPriceData, id: "price_free", amountType: "FREE" as const, priceAmount: 0, }, - ], - }; + }, + ]; - const result = CheckoutProductSchema.safeParse(completeProduct); - expect(result.success).toBe(true); + products.forEach((product) => { + const result = CheckoutProductSchema.safeParse(product); + expect(result.success).toBe(true); + }); }); }); }); From 1d35aed039d76e5749cec997eb85370f0fc1f25c Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 14 Jan 2026 11:17:47 -0500 Subject: [PATCH 3/4] feat(mdk-403): add CurrencySchema as single source of truth Create shared CurrencySchema (z.enum(["USD", "SAT"])) in schemas/currency.ts and use it throughout the API contract for type safety and validation. - Add src/schemas/currency.ts with CurrencySchema and Currency type - Update checkout.ts, products.ts, invoice.ts to use CurrencySchema - Export CurrencySchema from index.ts --- src/contracts/checkout.ts | 3 ++- src/contracts/products.ts | 3 ++- src/index.ts | 2 ++ src/schemas/checkout.ts | 3 ++- src/schemas/currency.ts | 9 +++++++++ src/schemas/invoice.ts | 3 ++- src/schemas/product.ts | 3 ++- 7 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 src/schemas/currency.ts diff --git a/src/contracts/checkout.ts b/src/contracts/checkout.ts index 687e830..2349ed2 100644 --- a/src/contracts/checkout.ts +++ b/src/contracts/checkout.ts @@ -1,6 +1,7 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; import { CheckoutSchema } from "../schemas/checkout"; +import { CurrencySchema } from "../schemas/currency"; /** * Helper to treat empty strings as undefined (not provided). @@ -47,7 +48,7 @@ export type CustomerInput = z.infer; export const CreateCheckoutInputSchema = z.object({ nodeId: z.string(), amount: z.number().optional(), - currency: z.string().optional(), + currency: CurrencySchema.optional(), products: z.array(z.string()).optional(), successUrl: z.string().optional(), allowDiscountCodes: z.boolean().optional(), diff --git a/src/contracts/products.ts b/src/contracts/products.ts index d1afb05..60166e5 100644 --- a/src/contracts/products.ts +++ b/src/contracts/products.ts @@ -1,11 +1,12 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; +import { CurrencySchema } from "../schemas/currency"; export const ProductPriceSchema = z.object({ id: z.string(), amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), priceAmount: z.number().nullable(), - currency: z.string(), + currency: CurrencySchema, }); export const ProductSchema = z.object({ diff --git a/src/index.ts b/src/index.ts index c253d7d..affa6d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ export type { } from "./contracts/onboarding"; export type { Checkout } from "./schemas/checkout"; export { CheckoutSchema } from "./schemas/checkout"; +export type { Currency } from "./schemas/currency"; +export { CurrencySchema } from "./schemas/currency"; export type { Product, ProductPrice } from "./contracts/products"; export { ProductSchema, diff --git a/src/schemas/checkout.ts b/src/schemas/checkout.ts index ba006f6..94704ce 100644 --- a/src/schemas/checkout.ts +++ b/src/schemas/checkout.ts @@ -6,6 +6,7 @@ import { PaidInvoiceSchema, } from "./invoice"; import { CheckoutProductSchema } from "./product"; +import { CurrencySchema } from "./currency"; /** * Valid fields that can be required at checkout time. @@ -42,7 +43,7 @@ const BaseCheckoutSchema = z.object({ expiresAt: z.date(), userMetadata: z.record(z.any()).nullable(), customFieldData: z.record(z.any()).nullable(), - currency: z.string(), + currency: CurrencySchema, allowDiscountCodes: z.boolean(), /** * Array of customer fields required at checkout. diff --git a/src/schemas/currency.ts b/src/schemas/currency.ts new file mode 100644 index 0000000..266fb59 --- /dev/null +++ b/src/schemas/currency.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +/** + * Supported currencies for pricing and payments. + * - USD: US Dollars (amounts in cents) + * - SAT: Satoshis (amounts in whole sats) + */ +export const CurrencySchema = z.enum(["USD", "SAT"]); +export type Currency = z.infer; diff --git a/src/schemas/invoice.ts b/src/schemas/invoice.ts index fd83a2b..59b7093 100644 --- a/src/schemas/invoice.ts +++ b/src/schemas/invoice.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CurrencySchema } from "./currency"; export const BaseInvoiceSchema = z.object({ invoice: z.string(), @@ -6,7 +7,7 @@ export const BaseInvoiceSchema = z.object({ paymentHash: z.string(), amountSats: z.number().nullable(), amountSatsReceived: z.number().nullable(), - currency: z.string(), + currency: CurrencySchema, fiatAmount: z.number().nullable(), btcPrice: z.number().nullable(), }); diff --git a/src/schemas/product.ts b/src/schemas/product.ts index 3ed86bf..4e7edef 100644 --- a/src/schemas/product.ts +++ b/src/schemas/product.ts @@ -1,10 +1,11 @@ import { z } from "zod"; +import { CurrencySchema } from "./currency"; export const CheckoutProductPriceSchema = z.object({ id: z.string(), amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), priceAmount: z.number().nullable(), - currency: z.string(), + currency: CurrencySchema, }); export const CheckoutProductSchema = z.object({ From 133223e26528ec30bd1daa36db0d2105dac41fa5 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Wed, 14 Jan 2026 11:21:33 -0500 Subject: [PATCH 4/4] fix: sort imports in checkout.ts for biome --- src/schemas/checkout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/checkout.ts b/src/schemas/checkout.ts index 94704ce..1e90a21 100644 --- a/src/schemas/checkout.ts +++ b/src/schemas/checkout.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CurrencySchema } from "./currency"; import { BaseInvoiceSchema, DynamicAmountPendingInvoiceSchema, @@ -6,7 +7,6 @@ import { PaidInvoiceSchema, } from "./invoice"; import { CheckoutProductSchema } from "./product"; -import { CurrencySchema } from "./currency"; /** * Valid fields that can be required at checkout time.