Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 74 additions & 93 deletions src/contracts/checkout.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { oc } from "@orpc/contract";
import { z } from "zod";
import { CheckoutSchema } from "../schemas/checkout";
import {
type CheckoutDetail,
CheckoutDetailSchema,
type CheckoutListItem,
CheckoutListItemSchema,
CheckoutSchema,
type CheckoutStatus,
CheckoutStatusSchema,
type CheckoutType,
CheckoutTypeSchema,
} from "../schemas/checkout";
import { CurrencySchema } from "../schemas/currency";
import { CustomerSchema } from "../schemas/customer";
import {
PaginationInputSchema,
PaginatedInputSchema,
PaginationOutputSchema,
} from "../schemas/pagination";

// Re-export entity schemas for backwards compatibility
export {
CheckoutStatusSchema,
CheckoutTypeSchema,
CheckoutListItemSchema,
CheckoutDetailSchema,
};
export type { CheckoutStatus, CheckoutType, CheckoutListItem, CheckoutDetail };

/**
* Helper to treat empty strings as undefined (not provided).
* This allows clients to pass empty strings without validation errors.
*/
const emptyStringToUndefined = z
.string()
Expand All @@ -23,22 +40,12 @@ const emailOrEmpty = z.string().email().optional().or(z.literal(""));

/**
* Valid fields that can be required at checkout time.
* - Standard fields: 'email', 'name' (checked against customer.email/name)
* - Any other string is a custom field (checked against customer[field])
*
* @example ['email'] - require email
* @example ['email', 'name'] - require both email and name
* @example ['email', 'company'] - require email and company
*/
export const CustomerFieldSchema = z.string().min(1);
export type CustomerField = string;

/**
* Customer data object for checkout.
* Flat structure - standard fields (name, email, externalId) plus any custom string fields.
* Empty strings are treated as undefined (not provided).
*
* @example { name: "John", email: "john@example.com", externalId: "user_123", company: "Acme" }
* Customer data object for checkout input.
*/
export const CustomerInputSchema = z
.object({
Expand All @@ -50,6 +57,7 @@ export const CustomerInputSchema = z

export type CustomerInput = z.infer<typeof CustomerInputSchema>;

// Input schemas
export const CreateCheckoutInputSchema = z.object({
nodeId: z.string(),
amount: z.number().optional(),
Expand All @@ -58,33 +66,13 @@ export const CreateCheckoutInputSchema = z.object({
successUrl: z.string().optional(),
allowDiscountCodes: z.boolean().optional(),
metadata: z.record(z.string(), z.any()).optional(),
/**
* Customer data for this checkout.
*/
customer: CustomerInputSchema.optional(),
/**
* Array of customer fields to require at checkout.
* If a field is listed here and not provided, the checkout UI will prompt for it.
* @example ['email'] - require email
* @example ['email', 'name'] - require both
*/
requireCustomerData: z.array(CustomerFieldSchema).optional(),
});

export const ConfirmCheckoutInputSchema = z.object({
checkoutId: z.string(),
/**
* Customer data provided at confirm time.
*/
customer: CustomerInputSchema.optional(),
/**
* Product selection at confirm time.
* - undefined or [] = keep current selection
* - [{ productId }] = change selection to this product
* - priceAmount required if selected price has amountType: CUSTOM
*
* Currently limited to single selection (max 1 item).
*/
products: z
.array(
z.object({
Expand Down Expand Up @@ -120,25 +108,64 @@ export const PaymentReceivedInputSchema = z.object({
),
});

export const GetCheckoutInputSchema = z.object({ id: z.string() });
export const GetCheckoutInputSchema = z.object({
id: z.string().describe("The checkout ID"),
});
export type GetCheckoutInput = z.infer<typeof GetCheckoutInputSchema>;

export type CreateCheckout = z.infer<typeof CreateCheckoutInputSchema>;
export type ConfirmCheckout = z.infer<typeof ConfirmCheckoutInputSchema>;
export type RegisterInvoice = z.infer<typeof RegisterInvoiceInputSchema>;
export type PaymentReceived = z.infer<typeof PaymentReceivedInputSchema>;

// List output schemas
export const ListCheckoutsOutputSchema = z.object({
checkouts: z.array(CheckoutSchema),
});
export type ListCheckoutsOutput = z.infer<typeof ListCheckoutsOutputSchema>;

export const ListCheckoutsPaginatedInputSchema = PaginatedInputSchema.extend({
status: CheckoutStatusSchema.optional().describe(
"Filter by status: UNCONFIRMED, CONFIRMED, PENDING_PAYMENT, PAYMENT_RECEIVED, or EXPIRED",
),
});
export type ListCheckoutsPaginatedInput = z.infer<
typeof ListCheckoutsPaginatedInputSchema
>;

export const ListCheckoutsPaginatedOutputSchema = PaginationOutputSchema.extend(
{
checkouts: z.array(CheckoutSchema),
},
);
export type ListCheckoutsPaginatedOutput = z.infer<
typeof ListCheckoutsPaginatedOutputSchema
>;

export const ListCheckoutsSummaryOutputSchema = PaginationOutputSchema.extend({
checkouts: z.array(CheckoutListItemSchema),
});
export type ListCheckoutsSummaryOutput = z.infer<
typeof ListCheckoutsSummaryOutputSchema
>;

// Contracts
export const createCheckoutContract = oc
.input(CreateCheckoutInputSchema)
.output(CheckoutSchema);

export const applyDiscountCodeContract = oc
.input(ApplyDiscountCodeInputSchema)
.output(CheckoutSchema);

export const confirmCheckoutContract = oc
.input(ConfirmCheckoutInputSchema)
.output(CheckoutSchema);

export const registerInvoiceContract = oc
.input(RegisterInvoiceInputSchema)
.output(CheckoutSchema);

export const getCheckoutContract = oc
.input(GetCheckoutInputSchema)
.output(CheckoutSchema);
Expand All @@ -147,67 +174,19 @@ export const paymentReceivedContract = oc
.input(PaymentReceivedInputSchema)
.output(z.object({ ok: z.boolean() }));

// List checkouts schemas
export const CheckoutStatusSchema = z.enum([
"UNCONFIRMED",
"CONFIRMED",
"PENDING_PAYMENT",
"PAYMENT_RECEIVED",
"EXPIRED",
]);
export type CheckoutStatus = z.infer<typeof CheckoutStatusSchema>;

export const CheckoutTypeSchema = z.enum(["PRODUCTS", "AMOUNT", "TOP_UP"]);
export type CheckoutType = z.infer<typeof CheckoutTypeSchema>;

const ListCheckoutsInputSchema = PaginationInputSchema.extend({
status: CheckoutStatusSchema.optional(),
});

const ListCheckoutsOutputSchema = PaginationOutputSchema.extend({
checkouts: z.array(CheckoutSchema),
});

export const listCheckoutsContract = oc
.input(ListCheckoutsInputSchema)
.input(z.object({}))
.output(ListCheckoutsOutputSchema);

const CheckoutCustomerSchema = CustomerSchema.nullable();

// MCP-specific summary schema for list (simpler than full CheckoutSchema)
const CheckoutListItemSchema = z.object({
id: z.string(),
status: CheckoutStatusSchema,
type: CheckoutTypeSchema,
currency: CurrencySchema,
totalAmount: z.number().nullable(),
customerId: z.string().nullable(),
customer: CheckoutCustomerSchema,
productId: z.string().nullable(),
organizationId: z.string(),
expiresAt: z.date(),
createdAt: z.date(),
modifiedAt: z.date().nullable(),
});

// MCP-specific detailed schema for get (includes additional fields)
const CheckoutDetailSchema = CheckoutListItemSchema.extend({
userMetadata: z.record(z.unknown()).nullable(),
successUrl: z.string().nullable(),
discountAmount: z.number().nullable(),
netAmount: z.number().nullable(),
taxAmount: z.number().nullable(),
});

const ListCheckoutsSummaryOutputSchema = PaginationOutputSchema.extend({
checkouts: z.array(CheckoutListItemSchema),
});
export const listCheckoutsPaginatedContract = oc
.input(ListCheckoutsPaginatedInputSchema)
.output(ListCheckoutsPaginatedOutputSchema);

export const listCheckoutsSummaryContract = oc
.input(ListCheckoutsInputSchema)
export const listCheckoutsSummaryPaginatedContract = oc
.input(ListCheckoutsPaginatedInputSchema)
.output(ListCheckoutsSummaryOutputSchema);

export const getCheckoutSummaryContract = oc
export const getCheckoutDetailContract = oc
.input(GetCheckoutInputSchema)
.output(CheckoutDetailSchema);

Expand All @@ -218,6 +197,8 @@ export const checkout = {
registerInvoice: registerInvoiceContract,
paymentReceived: paymentReceivedContract,
list: listCheckoutsContract,
listSummary: listCheckoutsSummaryContract,
getSummary: getCheckoutSummaryContract,
listPaginated: listCheckoutsPaginatedContract,
// Original names preserved
listSummary: listCheckoutsSummaryPaginatedContract,
getSummary: getCheckoutDetailContract,
};
100 changes: 82 additions & 18 deletions src/contracts/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,107 @@ import {
GetCustomerInputSchema as SdkGetCustomerInputSchema,
} from "../schemas/customer";
import {
PaginationInputSchema,
PaginatedInputSchema,
PaginationOutputSchema,
} from "../schemas/pagination";

// MCP-specific schemas
const ListCustomersInputSchema = PaginationInputSchema;
const ListCustomersOutputSchema = PaginationOutputSchema.extend({
// Simple list (no pagination)
export const ListCustomersOutputSchema = z.object({
customers: z.array(CustomerSchema),
});
export type ListCustomersOutput = z.infer<typeof ListCustomersOutputSchema>;

const McpGetCustomerInputSchema = z.object({ id: z.string() });
// Paginated list (no additional filters for customers)
export const ListCustomersPaginatedInputSchema = PaginatedInputSchema;
export type ListCustomersPaginatedInput = z.infer<
typeof ListCustomersPaginatedInputSchema
>;

const CreateCustomerInputSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
export const ListCustomersPaginatedOutputSchema = PaginationOutputSchema.extend(
{
customers: z.array(CustomerSchema),
},
);
export type ListCustomersPaginatedOutput = z.infer<
typeof ListCustomersPaginatedOutputSchema
>;

// Customer lookup by exactly one identifier (discriminated union for contract validation)
const CustomerLookupByIdSchema = z.object({
id: z.string().describe("The customer ID"),
});
const CustomerLookupByEmailSchema = z.object({
email: z.string().describe("The customer email address"),
});
const CustomerLookupByExternalIdSchema = z.object({
externalId: z.string().describe("The external ID from your system"),
});

const UpdateCustomerInputSchema = z.object({
id: z.string(),
name: z.string().optional(),
email: z.string().email().optional(),
userMetadata: z.record(z.string(), z.string()).optional(),
export const CustomerLookupInputSchema = z.union([
CustomerLookupByIdSchema,
CustomerLookupByEmailSchema,
CustomerLookupByExternalIdSchema,
]);
Comment on lines +45 to +49

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Enforce exactly one customer lookup identifier

CustomerLookupInputSchema is documented as “exactly one identifier,” but the union of non‑strict objects will accept payloads with multiple identifiers (e.g., { id, email }) and silently strip extras. That can cause a caller to pass conflicting identifiers and still get a result based only on the first matching branch. If you need to enforce exclusivity, add .strict() and a refinement or use a discriminated union so multi‑field payloads are rejected.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check 5c433b1

export type CustomerLookupInput = z.infer<typeof CustomerLookupInputSchema>;

// Flat schema for MCP tools (xmcp needs .shape, unions don't have it)
export const CustomerLookupToolSchema = z.object({
id: z.string().optional().describe("The customer ID"),
email: z.string().optional().describe("The customer email address"),
externalId: z
.string()
.optional()
.describe("The external ID from your system"),
});

const DeleteCustomerInputSchema = z.object({ id: z.string() });
export const GetCustomerInputSchema = CustomerLookupToolSchema;
export type GetCustomerInput = z.infer<typeof GetCustomerInputSchema>;

export const DeleteCustomerInputSchema = CustomerLookupToolSchema;
export type DeleteCustomerInput = z.infer<typeof DeleteCustomerInputSchema>;

export const CreateCustomerInputSchema = z.object({
name: z.string().min(1).describe("Customer name"),
email: z.string().email().describe("Customer email address"),
externalId: z
.string()
.optional()
.describe("External ID from your system for linking"),
});

export const UpdateCustomerInputSchema = z.object({
id: z.string().describe("The customer ID to update"),
name: z.string().optional().describe("New customer name"),
email: z.string().email().optional().describe("New customer email address"),
externalId: z
.string()
.optional()
.describe("External ID from your system for linking"),
userMetadata: z
.record(z.string(), z.string())
.optional()
.describe("Custom metadata key-value pairs"),
});

export type CreateCustomerInput = z.infer<typeof CreateCustomerInputSchema>;
export type UpdateCustomerInput = z.infer<typeof UpdateCustomerInputSchema>;

// SDK contract - uses flexible lookup (externalId/email/customerId)
export const getSdkCustomerContract = oc
.input(SdkGetCustomerInputSchema)
.output(CustomerWithSubscriptionsSchema);

// MCP contracts
// Contracts
export const listCustomersContract = oc
.input(ListCustomersInputSchema)
.input(z.object({}))
.output(ListCustomersOutputSchema);

export const listCustomersPaginatedContract = oc
.input(ListCustomersPaginatedInputSchema)
.output(ListCustomersPaginatedOutputSchema);

export const getCustomerContract = oc
.input(McpGetCustomerInputSchema)
.input(GetCustomerInputSchema)
.output(CustomerSchema);

export const createCustomerContract = oc
Expand All @@ -56,10 +119,11 @@ export const updateCustomerContract = oc

export const deleteCustomerContract = oc
.input(DeleteCustomerInputSchema)
.output(z.object({ ok: z.literal(true) }));
.output(z.void());

export const customer = {
list: listCustomersContract,
listPaginated: listCustomersPaginatedContract,
get: getCustomerContract,
getSdk: getSdkCustomerContract,
create: createCustomerContract,
Expand Down
Loading