Skip to content

Commit 679ccd0

Browse files
wip
1 parent 92bd88d commit 679ccd0

7 files changed

Lines changed: 175 additions & 156 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,6 @@
4646
"dependencies": {
4747
"@orpc/contract": "1.3.0",
4848
"vitest": "^3.2.2",
49-
"zod": "^3.24.4"
49+
"zod": "^3.25.42"
5050
}
5151
}

pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/contracts/checkout.ts

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,48 @@ import { z } from "zod";
33
import { CheckoutSchema } from "../schemas/checkout";
44

55
/**
6-
* Schema for customer metadata values.
7-
* Strings only, max 500 chars per value.
6+
* Helper to treat empty strings as undefined (not provided).
7+
* This allows clients to pass empty strings without validation errors.
88
*/
9-
const CustomerMetadataValueSchema = z.string().max(500);
9+
const emptyStringToUndefined = z.string().transform((val) => val.trim() === "" ? undefined : val);
1010

1111
/**
12-
* Schema for customer metadata object.
13-
* Max 50 keys, string values only (max 500 chars each).
14-
* This is only accepted at create time (server-side), NOT at confirm time (browser-callable)
15-
* to prevent malicious payers from overwriting merchant-set metadata.
12+
* Standard customer fields.
13+
* - 'name' → "Name"
14+
* - 'email' → "Email"
15+
* - 'externalId' → "External Id"
1616
*/
17-
const CustomerMetadataSchema = z
18-
.record(z.string(), CustomerMetadataValueSchema)
19-
.refine((obj) => Object.keys(obj).length <= 50, {
20-
message: "Customer metadata cannot have more than 50 keys",
17+
export const StandardCustomerFieldSchema = z.enum(["email", "name", "externalId"]);
18+
export type StandardCustomerField = z.infer<typeof StandardCustomerFieldSchema>;
19+
20+
/**
21+
* Valid fields that can be required at checkout time.
22+
* - Standard fields: 'email', 'name' (checked against customer.email/name)
23+
* - Any other string is a custom field (checked against customer[field])
24+
*
25+
* @example ['email'] - require email
26+
* @example ['email', 'name'] - require both email and name
27+
* @example ['email', 'company'] - require email and company
28+
*/
29+
export const CustomerFieldSchema = z.string().min(1);
30+
export type CustomerField = string;
31+
32+
/**
33+
* Customer data object for checkout.
34+
* Flat structure - standard fields (name, email, externalId) plus any custom string fields.
35+
* Empty strings are treated as undefined (not provided).
36+
*
37+
* @example { name: "John", email: "john@example.com", externalId: "user_123", company: "Acme" }
38+
*/
39+
export const CustomerInputSchema = z
40+
.object({
41+
name: emptyStringToUndefined.optional(),
42+
email: z.string().email().optional(),
43+
externalId: emptyStringToUndefined.optional(),
2144
})
22-
.optional();
45+
.catchall(z.string());
46+
47+
export type CustomerInput = z.infer<typeof CustomerInputSchema>;
2348

2449
export const CreateCheckoutInputSchema = z.object({
2550
nodeId: z.string(),
@@ -29,25 +54,39 @@ export const CreateCheckoutInputSchema = z.object({
2954
successUrl: z.string().optional(),
3055
allowDiscountCodes: z.boolean().optional(),
3156
metadata: z.record(z.string(), z.any()).optional(),
32-
customerMetadata: CustomerMetadataSchema,
33-
customerName: z.string().nonempty().optional(),
34-
customerEmail: z.string().email().optional(),
35-
customerIpAddress: z.string().ip().optional(),
36-
customerExternalId: z.string().nonempty().optional(),
37-
requireCustomerFields: z
38-
.object({
39-
customerName: z.boolean().optional(),
40-
customerEmail: z.boolean().optional(),
41-
})
42-
.optional(),
57+
/**
58+
* Customer data for this checkout.
59+
*/
60+
customer: CustomerInputSchema.optional(),
61+
/**
62+
* Array of customer fields to require at checkout.
63+
* If a field is listed here and not provided, the checkout UI will prompt for it.
64+
* @example ['email'] - require email
65+
* @example ['email', 'name'] - require both
66+
*/
67+
requireCustomerData: z.array(CustomerFieldSchema).optional(),
4368
});
4469

70+
/**
71+
* Customer data for confirm.
72+
* Accepts standard fields (name, email, externalId) plus any custom fields from the form.
73+
* Empty strings are treated as undefined (not provided).
74+
*/
75+
export const CustomerConfirmInputSchema = z
76+
.object({
77+
name: emptyStringToUndefined.optional(),
78+
email: z.string().email().optional(),
79+
externalId: emptyStringToUndefined.optional(),
80+
})
81+
.catchall(z.string());
82+
4583
export const ConfirmCheckoutInputSchema = z.object({
4684
checkoutId: z.string(),
47-
customerName: z.string().nonempty().optional(),
48-
customerEmail: z.string().email().optional(),
49-
customerIpAddress: z.string().ip().optional(),
50-
customerExternalId: z.string().nonempty().optional(),
85+
/**
86+
* Customer data provided at confirm time.
87+
* Note: metadata is NOT accepted here (browser-callable) to prevent tampering.
88+
*/
89+
customer: CustomerConfirmInputSchema.optional(),
5190
products: z
5291
.array(
5392
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

0 commit comments

Comments
 (0)