Skip to content

Commit 2752f2f

Browse files
feat(checkout): add customer object to checkout API
Replace individual customer fields (customerName, customerEmail, etc.) with unified customer object that supports custom fields. BREAKING CHANGE: customerName, customerEmail, customerExternalId replaced by customer object
1 parent e0fef80 commit 2752f2f

5 files changed

Lines changed: 200 additions & 71 deletions

File tree

src/contracts/checkout.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,48 @@ import { oc } from "@orpc/contract";
22
import { z } from "zod";
33
import { CheckoutSchema } from "../schemas/checkout";
44

5+
/**
6+
* Helper to treat empty strings as undefined (not provided).
7+
* This allows clients to pass empty strings without validation errors.
8+
*/
9+
const emptyStringToUndefined = z
10+
.string()
11+
.transform((val) => (val.trim() === "" ? undefined : val));
12+
13+
/**
14+
* Email field that accepts empty strings (treated as undefined) or valid emails.
15+
*/
16+
const emailOrEmpty = z.string().email().optional().or(z.literal(""));
17+
18+
/**
19+
* Valid fields that can be required at checkout time.
20+
* - Standard fields: 'email', 'name' (checked against customer.email/name)
21+
* - Any other string is a custom field (checked against customer[field])
22+
*
23+
* @example ['email'] - require email
24+
* @example ['email', 'name'] - require both email and name
25+
* @example ['email', 'company'] - require email and company
26+
*/
27+
export const CustomerFieldSchema = z.string().min(1);
28+
export type CustomerField = string;
29+
30+
/**
31+
* Customer data object for checkout.
32+
* Flat structure - standard fields (name, email, externalId) plus any custom string fields.
33+
* Empty strings are treated as undefined (not provided).
34+
*
35+
* @example { name: "John", email: "john@example.com", externalId: "user_123", company: "Acme" }
36+
*/
37+
export const CustomerInputSchema = z
38+
.object({
39+
name: emptyStringToUndefined.optional(),
40+
email: emailOrEmpty,
41+
externalId: emptyStringToUndefined.optional(),
42+
})
43+
.catchall(z.string());
44+
45+
export type CustomerInput = z.infer<typeof CustomerInputSchema>;
46+
547
export const CreateCheckoutInputSchema = z.object({
648
nodeId: z.string(),
749
amount: z.number().optional(),
@@ -10,24 +52,25 @@ export const CreateCheckoutInputSchema = z.object({
1052
successUrl: z.string().optional(),
1153
allowDiscountCodes: z.boolean().optional(),
1254
metadata: z.record(z.string(), z.any()).optional(),
13-
customerName: z.string().nonempty().optional(),
14-
customerEmail: z.string().email().optional(),
15-
customerIpAddress: z.string().ip().optional(),
16-
customerExternalId: z.string().nonempty().optional(),
17-
requireCustomerFields: z
18-
.object({
19-
customerName: z.boolean().optional(),
20-
customerEmail: z.boolean().optional(),
21-
})
22-
.optional(),
55+
/**
56+
* Customer data for this checkout.
57+
*/
58+
customer: CustomerInputSchema.optional(),
59+
/**
60+
* Array of customer fields to require at checkout.
61+
* If a field is listed here and not provided, the checkout UI will prompt for it.
62+
* @example ['email'] - require email
63+
* @example ['email', 'name'] - require both
64+
*/
65+
requireCustomerData: z.array(CustomerFieldSchema).optional(),
2366
});
2467

2568
export const ConfirmCheckoutInputSchema = z.object({
2669
checkoutId: z.string(),
27-
customerName: z.string().nonempty().optional(),
28-
customerEmail: z.string().email().optional(),
29-
customerIpAddress: z.string().ip().optional(),
30-
customerExternalId: z.string().nonempty().optional(),
70+
/**
71+
* Customer data provided at confirm time.
72+
*/
73+
customer: CustomerInputSchema.optional(),
3174
products: z
3275
.array(
3376
z.object({

src/schemas/checkout.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ import {
77
} from "./invoice";
88
import { CheckoutProductSchema } from "./product";
99

10+
/**
11+
* Valid fields that can be required at checkout time.
12+
* 'email', 'name', and 'externalId' are standard fields, anything else is a custom field.
13+
*/
14+
const CustomerFieldSchema = z.string().min(1);
15+
16+
/**
17+
* Customer data in checkout response.
18+
* Flat structure - standard fields (name, email, externalId) plus custom string fields.
19+
* Uses nullish() to accept both null and undefined from the database.
20+
*/
21+
const CustomerOutputSchema = z
22+
.object({
23+
name: z.string().nullish(),
24+
email: z.string().email().nullish(),
25+
externalId: z.string().nullish(),
26+
})
27+
.catchall(z.string());
28+
1029
const BaseCheckoutSchema = z.object({
1130
id: z.string(),
1231
createdAt: z.date(),
@@ -23,21 +42,19 @@ const BaseCheckoutSchema = z.object({
2342
expiresAt: z.date(),
2443
userMetadata: z.record(z.any()).nullable(),
2544
customFieldData: z.record(z.any()).nullable(),
26-
customerMetadata: z.record(z.any()).nullable(),
2745
currency: z.string(),
2846
allowDiscountCodes: z.boolean(),
29-
requireCustomerFields: z
30-
.object({
31-
customerName: z.boolean().optional(),
32-
customerEmail: z.boolean().optional(),
33-
})
34-
.nullable(),
47+
/**
48+
* Array of customer fields required at checkout.
49+
* @example ['email'] - email required
50+
* @example ['email', 'name'] - both required
51+
*/
52+
requireCustomerData: z.array(CustomerFieldSchema).nullable(),
3553
successUrl: z.string().nullable(),
36-
customerId: z.string().nullable(),
37-
customerExternalId: z.string().nullable(),
38-
customerName: z.string().nullable(),
39-
customerEmail: z.string().email().nullable(),
40-
customerIpAddress: z.string().nullable(),
54+
/**
55+
* Customer data associated with this checkout.
56+
*/
57+
customer: CustomerOutputSchema.nullable(),
4158
customerBillingAddress: z.record(z.any()).nullable(),
4259
products: z.array(CheckoutProductSchema).nullable(),
4360
providedAmount: z.number().nullable(),
@@ -62,7 +79,6 @@ const AmountFieldsSchema = z.object({
6279

6380
export const ExpiredCheckoutSchema = BaseCheckoutSchema.extend({
6481
status: z.literal("EXPIRED"),
65-
customerId: z.string().nullable(),
6682
type: z.enum(["PRODUCTS", "AMOUNT", "TOP_UP"]),
6783
});
6884

tests/contracts/checkout.test.ts

Lines changed: 97 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -63,75 +63,124 @@ describe('Checkout Contracts', () => {
6363
successUrl: 'https://example.com/success',
6464
allowDiscountCodes: true,
6565
metadata: { orderId: 'order_123' },
66-
customerName: 'John Doe',
67-
customerEmail: 'john@example.com',
68-
customerIpAddress: '192.168.1.1',
69-
customerExternalId: 'customer_ext_123',
70-
requireCustomerFields: {
71-
customerName: true,
72-
customerEmail: false,
66+
customer: {
67+
name: 'John Doe',
68+
email: 'john@example.com',
69+
plan: 'pro',
7370
},
71+
requireCustomerData: ['email', 'name'],
7472
};
7573

7674
const result = CreateCheckoutInputSchema.safeParse(input);
7775
expect(result.success).toBe(true);
7876
});
7977

80-
test('should reject invalid email format', () => {
78+
test('should validate requireCustomerData with just email', () => {
8179
const input = {
82-
customerEmail: 'invalid-email',
80+
nodeId: 'node_123',
81+
requireCustomerData: ['email'],
8382
};
8483

8584
const result = CreateCheckoutInputSchema.safeParse(input);
86-
expect(result.success).toBe(false);
85+
expect(result.success).toBe(true);
8786
});
8887

89-
test('should reject invalid IP address format', () => {
88+
test('should validate requireCustomerData with custom field', () => {
9089
const input = {
91-
customerIpAddress: 'not-an-ip',
90+
nodeId: 'node_123',
91+
requireCustomerData: ['email', 'company'],
92+
customer: {
93+
email: 'john@example.com',
94+
company: 'Acme Inc',
95+
},
9296
};
9397

9498
const result = CreateCheckoutInputSchema.safeParse(input);
95-
expect(result.success).toBe(false);
99+
expect(result.success).toBe(true);
96100
});
97101

98-
test('should reject empty string for customerName', () => {
102+
test('should accept any non-empty string in requireCustomerData (custom fields)', () => {
99103
const input = {
100-
customerName: '',
104+
nodeId: 'node_123',
105+
requireCustomerData: ['email', 'company', 'billingAddress'],
106+
};
107+
108+
const result = CreateCheckoutInputSchema.safeParse(input);
109+
expect(result.success).toBe(true);
110+
});
111+
112+
test('should reject empty string in requireCustomerData', () => {
113+
const input = {
114+
nodeId: 'node_123',
115+
requireCustomerData: ['email', ''],
101116
};
102117

103118
const result = CreateCheckoutInputSchema.safeParse(input);
104119
expect(result.success).toBe(false);
105120
});
106121

107-
test('should reject empty string for customerExternalId', () => {
122+
test('should reject invalid email format in customer', () => {
108123
const input = {
109-
customerExternalId: '',
124+
nodeId: 'node_123',
125+
customer: {
126+
email: 'invalid-email',
127+
},
110128
};
111129

112130
const result = CreateCheckoutInputSchema.safeParse(input);
113131
expect(result.success).toBe(false);
114132
});
115133

116-
test('should validate IPv4 address', () => {
134+
test('should transform empty string for customer name to undefined', () => {
117135
const input = {
118136
nodeId: 'node_123',
119-
customerIpAddress: '192.168.1.1',
137+
customer: {
138+
name: '',
139+
},
120140
};
121141

122142
const result = CreateCheckoutInputSchema.safeParse(input);
123143
expect(result.success).toBe(true);
144+
if (result.success) {
145+
expect(result.data.customer?.name).toBeUndefined();
146+
}
124147
});
125148

126-
test('should validate IPv6 address', () => {
149+
test('should validate create checkout with customer custom fields', () => {
127150
const input = {
128151
nodeId: 'node_123',
129-
customerIpAddress: '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
152+
customer: {
153+
userId: 'user_123',
154+
plan: 'pro',
155+
accountRef: 'acct_456',
156+
},
130157
};
131158

132159
const result = CreateCheckoutInputSchema.safeParse(input);
133160
expect(result.success).toBe(true);
134161
});
162+
163+
test('should only accept string values in custom fields', () => {
164+
const validInput = {
165+
nodeId: 'node_123',
166+
customer: {
167+
userId: 'user_123',
168+
company: 'Acme Inc',
169+
},
170+
};
171+
172+
const invalidInput = {
173+
nodeId: 'node_123',
174+
customer: {
175+
userId: 'user_123',
176+
count: 42, // numbers not allowed
177+
},
178+
};
179+
180+
expect(CreateCheckoutInputSchema.safeParse(validInput).success).toBe(true);
181+
expect(CreateCheckoutInputSchema.safeParse(invalidInput).success).toBe(false);
182+
});
183+
135184
});
136185

137186
describe('ConfirmCheckoutInputSchema', () => {
@@ -147,10 +196,10 @@ describe('Checkout Contracts', () => {
147196
test('should validate confirm checkout input with all fields', () => {
148197
const input = {
149198
checkoutId: 'checkout_123',
150-
customerName: 'John Doe',
151-
customerEmail: 'john@example.com',
152-
customerIpAddress: '192.168.1.1',
153-
customerExternalId: 'customer_ext_123',
199+
customer: {
200+
name: 'John Doe',
201+
email: 'john@example.com',
202+
},
154203
products: [
155204
{
156205
productId: 'product_1',
@@ -168,7 +217,9 @@ describe('Checkout Contracts', () => {
168217

169218
test('should reject confirm checkout without checkoutId', () => {
170219
const input = {
171-
customerName: 'John Doe',
220+
customer: {
221+
name: 'John Doe',
222+
},
172223
};
173224

174225
const result = ConfirmCheckoutInputSchema.safeParse(input);
@@ -187,6 +238,26 @@ describe('Checkout Contracts', () => {
187238
const result = ConfirmCheckoutInputSchema.safeParse(input);
188239
expect(result.success).toBe(true);
189240
});
241+
242+
test('should accept custom fields from confirm input (form fields)', () => {
243+
// Custom fields are accepted at confirm time - they come from the form
244+
const input = {
245+
checkoutId: 'checkout_123',
246+
customer: {
247+
name: 'John Doe',
248+
billingAddress: '123 Main St',
249+
planId: 'pro',
250+
},
251+
};
252+
253+
const result = ConfirmCheckoutInputSchema.safeParse(input);
254+
expect(result.success).toBe(true);
255+
if (result.success) {
256+
expect(result.data.customer).toHaveProperty('name');
257+
expect(result.data.customer).toHaveProperty('billingAddress');
258+
expect(result.data.customer).toHaveProperty('planId');
259+
}
260+
});
190261
});
191262

192263
describe('ApplyDiscountCodeInputSchema', () => {

tests/index.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,11 @@ describe('API Contract Index', () => {
4242
expiresAt: new Date(),
4343
userMetadata: null,
4444
customFieldData: null,
45-
customerMetadata: null,
4645
currency: 'USD',
4746
allowDiscountCodes: false,
48-
requireCustomerFields: null,
47+
requireCustomerData: null,
4948
successUrl: null,
50-
customerId: null,
51-
customerExternalId: null,
52-
customerName: null,
53-
customerEmail: null,
54-
customerIpAddress: null,
49+
customer: null,
5550
customerBillingAddress: null,
5651
products: [{
5752
id: 'product_123',

0 commit comments

Comments
 (0)