Skip to content

Commit 471daed

Browse files
authored
Merge pull request #8 from moneydevkit/mdk-403
feat(products): simplify to single price per product (Polar model)
2 parents 93b4a95 + 133223e commit 471daed

10 files changed

Lines changed: 80 additions & 68 deletions

File tree

src/contracts/checkout.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { oc } from "@orpc/contract";
22
import { z } from "zod";
33
import { CheckoutSchema } from "../schemas/checkout";
4+
import { CurrencySchema } from "../schemas/currency";
45

56
/**
67
* Helper to treat empty strings as undefined (not provided).
@@ -47,7 +48,7 @@ export type CustomerInput = z.infer<typeof CustomerInputSchema>;
4748
export const CreateCheckoutInputSchema = z.object({
4849
nodeId: z.string(),
4950
amount: z.number().optional(),
50-
currency: z.string().optional(),
51+
currency: CurrencySchema.optional(),
5152
products: z.array(z.string()).optional(),
5253
successUrl: z.string().optional(),
5354
allowDiscountCodes: z.boolean().optional(),

src/contracts/products.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { oc } from "@orpc/contract";
22
import { z } from "zod";
3+
import { CurrencySchema } from "../schemas/currency";
34

45
export const ProductPriceSchema = z.object({
56
id: z.string(),
67
amountType: z.enum(["FIXED", "CUSTOM", "FREE"]),
78
priceAmount: z.number().nullable(),
9+
currency: CurrencySchema,
810
});
911

1012
export const ProductSchema = z.object({
1113
id: z.string(),
1214
name: z.string(),
1315
description: z.string().nullable(),
1416
recurringInterval: z.enum(["MONTH", "QUARTER", "YEAR"]).nullable(),
15-
prices: z.array(ProductPriceSchema),
17+
price: ProductPriceSchema.nullable(),
1618
});
1719

1820
export const ListProductsOutputSchema = z.object({

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type {
1919
} from "./contracts/onboarding";
2020
export type { Checkout } from "./schemas/checkout";
2121
export { CheckoutSchema } from "./schemas/checkout";
22+
export type { Currency } from "./schemas/currency";
23+
export { CurrencySchema } from "./schemas/currency";
2224
export type { Product, ProductPrice } from "./contracts/products";
2325
export {
2426
ProductSchema,

src/schemas/checkout.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { CurrencySchema } from "./currency";
23
import {
34
BaseInvoiceSchema,
45
DynamicAmountPendingInvoiceSchema,
@@ -42,7 +43,7 @@ const BaseCheckoutSchema = z.object({
4243
expiresAt: z.date(),
4344
userMetadata: z.record(z.any()).nullable(),
4445
customFieldData: z.record(z.any()).nullable(),
45-
currency: z.string(),
46+
currency: CurrencySchema,
4647
allowDiscountCodes: z.boolean(),
4748
/**
4849
* Array of customer fields required at checkout.

src/schemas/currency.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Supported currencies for pricing and payments.
5+
* - USD: US Dollars (amounts in cents)
6+
* - SAT: Satoshis (amounts in whole sats)
7+
*/
8+
export const CurrencySchema = z.enum(["USD", "SAT"]);
9+
export type Currency = z.infer<typeof CurrencySchema>;

src/schemas/invoice.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { z } from "zod";
2+
import { CurrencySchema } from "./currency";
23

34
export const BaseInvoiceSchema = z.object({
45
invoice: z.string(),
56
expiresAt: z.date(),
67
paymentHash: z.string(),
78
amountSats: z.number().nullable(),
89
amountSatsReceived: z.number().nullable(),
9-
currency: z.string(),
10+
currency: CurrencySchema,
1011
fiatAmount: z.number().nullable(),
1112
btcPrice: z.number().nullable(),
1213
});

src/schemas/product.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { z } from "zod";
2+
import { CurrencySchema } from "./currency";
23

34
export const CheckoutProductPriceSchema = z.object({
45
id: z.string(),
56
amountType: z.enum(["FIXED", "CUSTOM", "FREE"]),
67
priceAmount: z.number().nullable(),
8+
currency: CurrencySchema,
79
});
810

911
export const CheckoutProductSchema = z.object({
1012
id: z.string(),
1113
name: z.string(),
1214
description: z.string().nullable(),
1315
recurringInterval: z.enum(["MONTH", "QUARTER", "YEAR"]).nullable(),
14-
prices: z.array(CheckoutProductPriceSchema),
16+
price: CheckoutProductPriceSchema.nullable(),
1517
});

tests/index.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,12 @@ describe('API Contract Index', () => {
5353
name: 'Test Product',
5454
description: null,
5555
recurringInterval: null,
56-
prices: [{
56+
price: {
5757
id: 'price_123',
5858
amountType: 'FIXED' as const,
5959
priceAmount: 1000,
60-
minimumAmount: null,
61-
maximumAmount: null,
62-
presetAmount: null,
63-
unitAmount: null,
64-
capAmount: null,
65-
meterId: null,
66-
}],
60+
currency: 'USD',
61+
},
6762
}],
6863
providedAmount: null,
6964
totalAmount: null,
@@ -196,4 +191,4 @@ describe('API Contract Index', () => {
196191
expect(contract.checkout.paymentReceived).toBeDefined();
197192
});
198193
});
199-
});
194+
});

tests/schemas/checkout.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,12 @@ const mockProduct = {
3939
name: 'Test Product',
4040
description: 'A test product',
4141
recurringInterval: null,
42-
prices: [{
42+
price: {
4343
id: 'price_123',
4444
amountType: 'FIXED' as const,
4545
priceAmount: 1000,
46-
minimumAmount: null,
47-
maximumAmount: null,
48-
presetAmount: null,
49-
unitAmount: null,
50-
capAmount: null,
51-
meterId: null,
52-
}],
46+
currency: 'USD',
47+
},
5348
};
5449

5550
const mockInvoice = {
@@ -388,4 +383,4 @@ describe('CheckoutSchema', () => {
388383
expect(result.success).toBe(false);
389384
});
390385
});
391-
});
386+
});

tests/schemas/product.test.ts

Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ const baseProductPriceData = {
88
id: "price_123",
99
amountType: "FIXED" as const,
1010
priceAmount: null,
11+
currency: "USD",
1112
};
1213

1314
const baseProductData = {
1415
id: "product_123",
1516
name: "Test Product",
1617
description: null,
1718
recurringInterval: null,
18-
prices: [baseProductPriceData],
19+
price: baseProductPriceData,
1920
};
2021

2122
describe("Product Schemas", () => {
@@ -144,24 +145,18 @@ describe("Product Schemas", () => {
144145
});
145146
});
146147

147-
test("should validate product with multiple prices", () => {
148-
const productWithMultiplePrices = {
148+
test("should validate product with a custom price", () => {
149+
const productWithCustomPrice = {
149150
...baseProductData,
150-
prices: [
151-
{
152-
id: "price_1",
153-
amountType: "FIXED" as const,
154-
priceAmount: 999,
155-
},
156-
{
157-
id: "price_2",
158-
amountType: "CUSTOM" as const,
159-
priceAmount: null,
160-
},
161-
],
151+
price: {
152+
...baseProductPriceData,
153+
id: "price_2",
154+
amountType: "CUSTOM" as const,
155+
priceAmount: null,
156+
},
162157
};
163158

164-
const result = CheckoutProductSchema.safeParse(productWithMultiplePrices);
159+
const result = CheckoutProductSchema.safeParse(productWithCustomPrice);
165160
expect(result.success).toBe(true);
166161
});
167162

@@ -185,23 +180,23 @@ describe("Product Schemas", () => {
185180
expect(result.success).toBe(false);
186181
});
187182

188-
test("should reject product without prices array", () => {
189-
const productWithoutPrices = {
183+
test("should reject product without price field", () => {
184+
const productWithoutPrice = {
190185
...baseProductData,
191-
prices: undefined,
186+
price: undefined,
192187
};
193188

194-
const result = CheckoutProductSchema.safeParse(productWithoutPrices);
189+
const result = CheckoutProductSchema.safeParse(productWithoutPrice);
195190
expect(result.success).toBe(false);
196191
});
197192

198-
test("should allow product with empty prices array", () => {
199-
const productWithEmptyPrices = {
193+
test("should allow product with null price", () => {
194+
const productWithNullPrice = {
200195
...baseProductData,
201-
prices: [],
196+
price: null,
202197
};
203198

204-
const result = CheckoutProductSchema.safeParse(productWithEmptyPrices);
199+
const result = CheckoutProductSchema.safeParse(productWithNullPrice);
205200
expect(result.success).toBe(true);
206201
});
207202

@@ -215,15 +210,13 @@ describe("Product Schemas", () => {
215210
expect(result.success).toBe(false);
216211
});
217212

218-
test("should reject product with invalid price in prices array", () => {
213+
test("should reject product with invalid price object", () => {
219214
const productWithInvalidPrice = {
220215
...baseProductData,
221-
prices: [
222-
{
223-
...baseProductPriceData,
224-
amountType: "INVALID_TYPE" as any,
225-
},
226-
],
216+
price: {
217+
...baseProductPriceData,
218+
amountType: "INVALID_TYPE" as any,
219+
},
227220
};
228221

229222
const result = CheckoutProductSchema.safeParse(productWithInvalidPrice);
@@ -252,33 +245,44 @@ describe("Product Schemas", () => {
252245
});
253246

254247
describe("Integration scenarios", () => {
255-
test("should validate complete product with all supported price types", () => {
256-
const completeProduct = {
257-
id: "product_complete",
258-
name: "Complete Product",
259-
description: "A product with all types of prices",
260-
recurringInterval: "MONTH" as const,
261-
prices: [
262-
{
248+
test("should validate products with all supported price types", () => {
249+
const products = [
250+
{
251+
...baseProductData,
252+
id: "product_fixed",
253+
price: {
254+
...baseProductPriceData,
263255
id: "price_fixed",
264256
amountType: "FIXED" as const,
265257
priceAmount: 2999,
266258
},
267-
{
259+
},
260+
{
261+
...baseProductData,
262+
id: "product_custom",
263+
price: {
264+
...baseProductPriceData,
268265
id: "price_custom",
269266
amountType: "CUSTOM" as const,
270267
priceAmount: null,
271268
},
272-
{
269+
},
270+
{
271+
...baseProductData,
272+
id: "product_free",
273+
price: {
274+
...baseProductPriceData,
273275
id: "price_free",
274276
amountType: "FREE" as const,
275277
priceAmount: 0,
276278
},
277-
],
278-
};
279+
},
280+
];
279281

280-
const result = CheckoutProductSchema.safeParse(completeProduct);
281-
expect(result.success).toBe(true);
282+
products.forEach((product) => {
283+
const result = CheckoutProductSchema.safeParse(product);
284+
expect(result.success).toBe(true);
285+
});
282286
});
283287
});
284288
});

0 commit comments

Comments
 (0)