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 1682354..60166e5 100644 --- a/src/contracts/products.ts +++ b/src/contracts/products.ts @@ -1,10 +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: CurrencySchema, }); export const ProductSchema = z.object({ @@ -12,7 +14,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/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..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, @@ -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 b25984b..4e7edef 100644 --- a/src/schemas/product.ts +++ b/src/schemas/product.ts @@ -1,9 +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: CurrencySchema, }); export const CheckoutProductSchema = z.object({ @@ -11,5 +13,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(), }); 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); + }); }); }); });