From 3d02c3b212eef5939fe0bf09ca173cf95c750248 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Fri, 9 Jan 2026 13:09:26 -0500 Subject: [PATCH 1/2] feat: add products.list contract Add products contract with: - ProductSchema (id, name, description, recurringInterval, prices) - ProductPriceSchema (amountType, amounts, meterId) - listProductsContract for products.list endpoint --- src/contracts/products.ts | 31 ++ src/index.ts | 9 +- src/schemas/product.ts | 8 +- tests/schemas/product.test.ts | 611 +++++++++++++++------------------- 4 files changed, 316 insertions(+), 343 deletions(-) create mode 100644 src/contracts/products.ts diff --git a/src/contracts/products.ts b/src/contracts/products.ts new file mode 100644 index 0000000..1682354 --- /dev/null +++ b/src/contracts/products.ts @@ -0,0 +1,31 @@ +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +export const ProductPriceSchema = z.object({ + id: z.string(), + amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), + priceAmount: z.number().nullable(), +}); + +export const ProductSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + recurringInterval: z.enum(["MONTH", "QUARTER", "YEAR"]).nullable(), + prices: z.array(ProductPriceSchema), +}); + +export const ListProductsOutputSchema = z.object({ + products: z.array(ProductSchema), +}); + +export type Product = z.infer; +export type ProductPrice = z.infer; + +export const listProductsContract = oc + .input(z.object({}).optional()) + .output(ListProductsOutputSchema); + +export const products = { + list: listProductsContract, +}; diff --git a/src/index.ts b/src/index.ts index 7c81dae..c253d7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { checkout } from "./contracts/checkout"; import { onboarding } from "./contracts/onboarding"; +import { products } from "./contracts/products"; export type { ConfirmCheckout, @@ -18,8 +19,14 @@ export type { } from "./contracts/onboarding"; export type { Checkout } from "./schemas/checkout"; export { CheckoutSchema } from "./schemas/checkout"; +export type { Product, ProductPrice } from "./contracts/products"; +export { + ProductSchema, + ProductPriceSchema, + ListProductsOutputSchema, +} from "./contracts/products"; -export const contract = { checkout, onboarding }; +export const contract = { checkout, onboarding, products }; export type { MetadataValidationError } from "./validation/metadata-validation"; export { diff --git a/src/schemas/product.ts b/src/schemas/product.ts index 2fff30a..b25984b 100644 --- a/src/schemas/product.ts +++ b/src/schemas/product.ts @@ -2,14 +2,8 @@ import { z } from "zod"; export const CheckoutProductPriceSchema = z.object({ id: z.string(), - amountType: z.enum(["FIXED", "CUSTOM", "FREE", "METERED"]), + amountType: z.enum(["FIXED", "CUSTOM", "FREE"]), priceAmount: z.number().nullable(), - minimumAmount: z.number().nullable(), - maximumAmount: z.number().nullable(), - presetAmount: z.number().nullable(), - unitAmount: z.number().nullable(), - capAmount: z.number().nullable(), - meterId: z.string().nullable(), }); export const CheckoutProductSchema = z.object({ diff --git a/tests/schemas/product.test.ts b/tests/schemas/product.test.ts index 407e40f..4cae177 100644 --- a/tests/schemas/product.test.ts +++ b/tests/schemas/product.test.ts @@ -1,343 +1,284 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect } from "vitest"; import { - CheckoutProductSchema, - CheckoutProductPriceSchema, -} from '../../src/schemas/product'; + CheckoutProductSchema, + CheckoutProductPriceSchema, +} from "../../src/schemas/product"; const baseProductPriceData = { - id: 'price_123', - amountType: 'FIXED' as const, - priceAmount: null, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, + id: "price_123", + amountType: "FIXED" as const, + priceAmount: null, }; const baseProductData = { - id: 'product_123', - name: 'Test Product', - description: null, - recurringInterval: null, - prices: [baseProductPriceData], + id: "product_123", + name: "Test Product", + description: null, + recurringInterval: null, + prices: [baseProductPriceData], }; -describe('Product Schemas', () => { - describe('CheckoutProductPriceSchema', () => { - test('should validate price with FIXED amount type', () => { - const fixedPrice = { - ...baseProductPriceData, - amountType: 'FIXED' as const, - priceAmount: 999, - }; - - const result = CheckoutProductPriceSchema.safeParse(fixedPrice); - expect(result.success).toBe(true); - }); - - test('should validate price with CUSTOM amount type', () => { - const customPrice = { - ...baseProductPriceData, - amountType: 'CUSTOM' as const, - minimumAmount: 100, - maximumAmount: 10000, - presetAmount: 500, - }; - - const result = CheckoutProductPriceSchema.safeParse(customPrice); - expect(result.success).toBe(true); - }); - - test('should validate price with FREE amount type', () => { - const freePrice = { - ...baseProductPriceData, - amountType: 'FREE' as const, - priceAmount: 0, - }; - - const result = CheckoutProductPriceSchema.safeParse(freePrice); - expect(result.success).toBe(true); - }); - - test('should validate price with METERED amount type', () => { - const meteredPrice = { - ...baseProductPriceData, - amountType: 'METERED' as const, - unitAmount: 50, - capAmount: 1000, - meterId: 'meter_123', - }; - - const result = CheckoutProductPriceSchema.safeParse(meteredPrice); - expect(result.success).toBe(true); - }); - - test('should reject invalid amount type', () => { - const invalidPrice = { - ...baseProductPriceData, - amountType: 'INVALID_TYPE' as any, - }; - - const result = CheckoutProductPriceSchema.safeParse(invalidPrice); - expect(result.success).toBe(false); - }); - - test('should reject price without required id', () => { - const priceWithoutId = { - ...baseProductPriceData, - id: undefined, - }; - - const result = CheckoutProductPriceSchema.safeParse(priceWithoutId); - expect(result.success).toBe(false); - }); - - test('should allow null values for optional amount fields', () => { - const priceWithNulls = { - ...baseProductPriceData, - priceAmount: null, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, - }; - - const result = CheckoutProductPriceSchema.safeParse(priceWithNulls); - expect(result.success).toBe(true); - }); - - test('should reject non-number values for amount fields', () => { - const invalidPrice = { - ...baseProductPriceData, - priceAmount: 'not-a-number', - }; - - const result = CheckoutProductPriceSchema.safeParse(invalidPrice); - expect(result.success).toBe(false); - }); - - test('should reject non-string meterId', () => { - const invalidPrice = { - ...baseProductPriceData, - meterId: 123, - }; - - const result = CheckoutProductPriceSchema.safeParse(invalidPrice); - expect(result.success).toBe(false); - }); - }); - - describe('CheckoutProductSchema', () => { - test('should validate product with basic information', () => { - const result = CheckoutProductSchema.safeParse(baseProductData); - expect(result.success).toBe(true); - }); - - test('should validate product with description', () => { - const productWithDescription = { - ...baseProductData, - description: 'This is a test product description', - }; - - const result = CheckoutProductSchema.safeParse(productWithDescription); - expect(result.success).toBe(true); - }); - - test('should validate product with recurring interval', () => { - const recurringProduct = { - ...baseProductData, - recurringInterval: 'MONTH' as const, - }; - - const result = CheckoutProductSchema.safeParse(recurringProduct); - expect(result.success).toBe(true); - }); - - test('should validate all recurring interval options', () => { - const intervals = ['MONTH', 'QUARTER', 'YEAR'] as const; - - intervals.forEach(interval => { - const product = { - ...baseProductData, - recurringInterval: interval, - }; - - const result = CheckoutProductSchema.safeParse(product); - expect(result.success).toBe(true); - }); - }); - - test('should validate product with multiple prices', () => { - const productWithMultiplePrices = { - ...baseProductData, - prices: [ - { - ...baseProductPriceData, - id: 'price_1', - amountType: 'FIXED' as const, - priceAmount: 999, - }, - { - ...baseProductPriceData, - id: 'price_2', - amountType: 'CUSTOM' as const, - minimumAmount: 100, - maximumAmount: 1000, - }, - ], - }; - - const result = CheckoutProductSchema.safeParse(productWithMultiplePrices); - expect(result.success).toBe(true); - }); - - test('should reject product without required id', () => { - const productWithoutId = { - ...baseProductData, - id: undefined, - }; - - const result = CheckoutProductSchema.safeParse(productWithoutId); - expect(result.success).toBe(false); - }); - - test('should reject product without required name', () => { - const productWithoutName = { - ...baseProductData, - name: undefined, - }; - - const result = CheckoutProductSchema.safeParse(productWithoutName); - expect(result.success).toBe(false); - }); - - test('should reject product without prices array', () => { - const productWithoutPrices = { - ...baseProductData, - prices: undefined, - }; - - const result = CheckoutProductSchema.safeParse(productWithoutPrices); - expect(result.success).toBe(false); - }); - - test('should allow product with empty prices array', () => { - const productWithEmptyPrices = { - ...baseProductData, - prices: [], - }; - - const result = CheckoutProductSchema.safeParse(productWithEmptyPrices); - expect(result.success).toBe(true); - }); - - test('should reject product with invalid recurring interval', () => { - const productWithInvalidInterval = { - ...baseProductData, - recurringInterval: 'WEEKLY' as any, - }; - - const result = CheckoutProductSchema.safeParse(productWithInvalidInterval); - expect(result.success).toBe(false); - }); - - test('should reject product with invalid price in prices array', () => { - const productWithInvalidPrice = { - ...baseProductData, - prices: [ - { - ...baseProductPriceData, - amountType: 'INVALID_TYPE' as any, - }, - ], - }; - - const result = CheckoutProductSchema.safeParse(productWithInvalidPrice); - expect(result.success).toBe(false); - }); - - test('should handle null description properly', () => { - const productWithNullDescription = { - ...baseProductData, - description: null, - }; - - const result = CheckoutProductSchema.safeParse(productWithNullDescription); - expect(result.success).toBe(true); - }); - - test('should handle null recurringInterval properly', () => { - const productWithNullInterval = { - ...baseProductData, - recurringInterval: null, - }; - - const result = CheckoutProductSchema.safeParse(productWithNullInterval); - expect(result.success).toBe(true); - }); - }); - - describe('Integration scenarios', () => { - test('should validate complete product with all price types', () => { - const completeProduct = { - id: 'product_complete', - name: 'Complete Product', - description: 'A product with all types of prices', - recurringInterval: 'MONTH' as const, - prices: [ - { - id: 'price_fixed', - amountType: 'FIXED' as const, - priceAmount: 2999, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, - }, - { - id: 'price_custom', - amountType: 'CUSTOM' as const, - priceAmount: null, - minimumAmount: 100, - maximumAmount: 5000, - presetAmount: 1000, - unitAmount: null, - capAmount: null, - meterId: null, - }, - { - id: 'price_free', - amountType: 'FREE' as const, - priceAmount: 0, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: null, - capAmount: null, - meterId: null, - }, - { - id: 'price_metered', - amountType: 'METERED' as const, - priceAmount: null, - minimumAmount: null, - maximumAmount: null, - presetAmount: null, - unitAmount: 50, - capAmount: 2000, - meterId: 'meter_usage', - }, - ], - }; - - const result = CheckoutProductSchema.safeParse(completeProduct); - expect(result.success).toBe(true); - }); - }); -}); \ No newline at end of file +describe("Product Schemas", () => { + describe("CheckoutProductPriceSchema", () => { + test("should validate price with FIXED amount type", () => { + const fixedPrice = { + ...baseProductPriceData, + amountType: "FIXED" as const, + priceAmount: 999, + }; + + const result = CheckoutProductPriceSchema.safeParse(fixedPrice); + expect(result.success).toBe(true); + }); + + test("should validate price with CUSTOM amount type", () => { + const customPrice = { + ...baseProductPriceData, + amountType: "CUSTOM" as const, + priceAmount: null, + }; + + const result = CheckoutProductPriceSchema.safeParse(customPrice); + expect(result.success).toBe(true); + }); + + test("should validate price with FREE amount type", () => { + const freePrice = { + ...baseProductPriceData, + amountType: "FREE" as const, + priceAmount: 0, + }; + + const result = CheckoutProductPriceSchema.safeParse(freePrice); + expect(result.success).toBe(true); + }); + + test("should reject METERED amount type", () => { + const meteredPrice = { + ...baseProductPriceData, + amountType: "METERED" as const, + }; + + const result = CheckoutProductPriceSchema.safeParse(meteredPrice); + expect(result.success).toBe(false); + }); + + test("should reject invalid amount type", () => { + const invalidPrice = { + ...baseProductPriceData, + amountType: "INVALID_TYPE" as any, + }; + + const result = CheckoutProductPriceSchema.safeParse(invalidPrice); + expect(result.success).toBe(false); + }); + + test("should reject price without required id", () => { + const priceWithoutId = { + ...baseProductPriceData, + id: undefined, + }; + + const result = CheckoutProductPriceSchema.safeParse(priceWithoutId); + expect(result.success).toBe(false); + }); + + test("should allow null priceAmount", () => { + const priceWithNull = { + ...baseProductPriceData, + priceAmount: null, + }; + + const result = CheckoutProductPriceSchema.safeParse(priceWithNull); + expect(result.success).toBe(true); + }); + + test("should reject non-number values for priceAmount", () => { + const invalidPrice = { + ...baseProductPriceData, + priceAmount: "not-a-number", + }; + + const result = CheckoutProductPriceSchema.safeParse(invalidPrice); + expect(result.success).toBe(false); + }); + }); + + describe("CheckoutProductSchema", () => { + test("should validate product with basic information", () => { + const result = CheckoutProductSchema.safeParse(baseProductData); + expect(result.success).toBe(true); + }); + + test("should validate product with description", () => { + const productWithDescription = { + ...baseProductData, + description: "This is a test product description", + }; + + const result = CheckoutProductSchema.safeParse(productWithDescription); + expect(result.success).toBe(true); + }); + + test("should validate product with recurring interval", () => { + const recurringProduct = { + ...baseProductData, + recurringInterval: "MONTH" as const, + }; + + const result = CheckoutProductSchema.safeParse(recurringProduct); + expect(result.success).toBe(true); + }); + + test("should validate all recurring interval options", () => { + const intervals = ["MONTH", "QUARTER", "YEAR"] as const; + + intervals.forEach((interval) => { + const product = { + ...baseProductData, + recurringInterval: interval, + }; + + const result = CheckoutProductSchema.safeParse(product); + expect(result.success).toBe(true); + }); + }); + + test("should validate product with multiple prices", () => { + const productWithMultiplePrices = { + ...baseProductData, + prices: [ + { + id: "price_1", + amountType: "FIXED" as const, + priceAmount: 999, + }, + { + id: "price_2", + amountType: "CUSTOM" as const, + priceAmount: null, + }, + ], + }; + + const result = CheckoutProductSchema.safeParse(productWithMultiplePrices); + expect(result.success).toBe(true); + }); + + test("should reject product without required id", () => { + const productWithoutId = { + ...baseProductData, + id: undefined, + }; + + const result = CheckoutProductSchema.safeParse(productWithoutId); + expect(result.success).toBe(false); + }); + + test("should reject product without required name", () => { + const productWithoutName = { + ...baseProductData, + name: undefined, + }; + + const result = CheckoutProductSchema.safeParse(productWithoutName); + expect(result.success).toBe(false); + }); + + test("should reject product without prices array", () => { + const productWithoutPrices = { + ...baseProductData, + prices: undefined, + }; + + const result = CheckoutProductSchema.safeParse(productWithoutPrices); + expect(result.success).toBe(false); + }); + + test("should allow product with empty prices array", () => { + const productWithEmptyPrices = { + ...baseProductData, + prices: [], + }; + + const result = CheckoutProductSchema.safeParse(productWithEmptyPrices); + expect(result.success).toBe(true); + }); + + test("should reject product with invalid recurring interval", () => { + const productWithInvalidInterval = { + ...baseProductData, + recurringInterval: "WEEKLY" as any, + }; + + const result = CheckoutProductSchema.safeParse(productWithInvalidInterval); + expect(result.success).toBe(false); + }); + + test("should reject product with invalid price in prices array", () => { + const productWithInvalidPrice = { + ...baseProductData, + prices: [ + { + ...baseProductPriceData, + amountType: "INVALID_TYPE" as any, + }, + ], + }; + + const result = CheckoutProductSchema.safeParse(productWithInvalidPrice); + expect(result.success).toBe(false); + }); + + test("should handle null description properly", () => { + const productWithNullDescription = { + ...baseProductData, + description: null, + }; + + const result = CheckoutProductSchema.safeParse(productWithNullDescription); + expect(result.success).toBe(true); + }); + + test("should handle null recurringInterval properly", () => { + const productWithNullInterval = { + ...baseProductData, + recurringInterval: null, + }; + + const result = CheckoutProductSchema.safeParse(productWithNullInterval); + expect(result.success).toBe(true); + }); + }); + + 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: [ + { + id: "price_fixed", + amountType: "FIXED" as const, + priceAmount: 2999, + }, + { + id: "price_custom", + amountType: "CUSTOM" as const, + priceAmount: null, + }, + { + id: "price_free", + amountType: "FREE" as const, + priceAmount: 0, + }, + ], + }; + + const result = CheckoutProductSchema.safeParse(completeProduct); + expect(result.success).toBe(true); + }); + }); +}); From d7cf0be07e8c6157daa954561688bff0831787a3 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 13 Jan 2026 11:54:41 -0500 Subject: [PATCH 2/2] chore: bump version to 0.1.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 48634c7..c119af9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moneydevkit/api-contract", - "version": "0.1.13", + "version": "0.1.14", "description": "API Contract for moneydevkit", "main": "./dist/index.cjs", "module": "./dist/index.js",