diff --git a/src/contracts/checkout.ts b/src/contracts/checkout.ts index 2c6b848..1cc9026 100644 --- a/src/contracts/checkout.ts +++ b/src/contracts/checkout.ts @@ -2,6 +2,11 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; import { CheckoutSchema } from "../schemas/checkout"; import { CurrencySchema } from "../schemas/currency"; +import { CustomerSchema } from "../schemas/customer"; +import { + PaginationInputSchema, + PaginationOutputSchema, +} from "../schemas/pagination"; /** * Helper to treat empty strings as undefined (not provided). @@ -142,10 +147,74 @@ export const paymentReceivedContract = oc .input(PaymentReceivedInputSchema) .output(z.object({ ok: z.boolean() })); +// List checkouts schemas +const CheckoutStatusSchema = z.enum([ + "UNCONFIRMED", + "CONFIRMED", + "PENDING_PAYMENT", + "PAYMENT_RECEIVED", + "EXPIRED", +]); + +const ListCheckoutsInputSchema = PaginationInputSchema.extend({ + status: CheckoutStatusSchema.optional(), +}); + +const ListCheckoutsOutputSchema = PaginationOutputSchema.extend({ + checkouts: z.array(CheckoutSchema), +}); + +export const listCheckoutsContract = oc + .input(ListCheckoutsInputSchema) + .output(ListCheckoutsOutputSchema); + +// MCP-specific embedded customer schema +const CheckoutCustomerSchema = CustomerSchema.nullable(); + +// MCP-specific summary schema for list (simpler than full CheckoutSchema) +const CheckoutListItemSchema = z.object({ + id: z.string(), + status: CheckoutStatusSchema, + type: z.enum(["PRODUCTS", "AMOUNT", "TOP_UP"]), + 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 listCheckoutsSummaryContract = oc + .input(ListCheckoutsInputSchema) + .output(ListCheckoutsSummaryOutputSchema); + +export const getCheckoutSummaryContract = oc + .input(GetCheckoutInputSchema) + .output(CheckoutDetailSchema); + export const checkout = { get: getCheckoutContract, create: createCheckoutContract, confirm: confirmCheckoutContract, registerInvoice: registerInvoiceContract, paymentReceived: paymentReceivedContract, + list: listCheckoutsContract, + listSummary: listCheckoutsSummaryContract, + getSummary: getCheckoutSummaryContract, }; diff --git a/src/contracts/customer.ts b/src/contracts/customer.ts new file mode 100644 index 0000000..699edfc --- /dev/null +++ b/src/contracts/customer.ts @@ -0,0 +1,56 @@ +import { oc } from "@orpc/contract"; +import { z } from "zod"; +import { CustomerSchema } from "../schemas/customer"; +import { + PaginationInputSchema, + PaginationOutputSchema, +} from "../schemas/pagination"; + +const ListCustomersInputSchema = PaginationInputSchema; +const ListCustomersOutputSchema = PaginationOutputSchema.extend({ + customers: z.array(CustomerSchema), +}); + +const GetCustomerInputSchema = z.object({ id: z.string() }); + +const CreateCustomerInputSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); + +const UpdateCustomerInputSchema = z.object({ + id: z.string(), + name: z.string().optional(), + email: z.string().email().optional(), + userMetadata: z.record(z.string(), z.string()).optional(), +}); + +const DeleteCustomerInputSchema = z.object({ id: z.string() }); + +export const listCustomersContract = oc + .input(ListCustomersInputSchema) + .output(ListCustomersOutputSchema); + +export const getCustomerContract = oc + .input(GetCustomerInputSchema) + .output(CustomerSchema); + +export const createCustomerContract = oc + .input(CreateCustomerInputSchema) + .output(CustomerSchema); + +export const updateCustomerContract = oc + .input(UpdateCustomerInputSchema) + .output(CustomerSchema); + +export const deleteCustomerContract = oc + .input(DeleteCustomerInputSchema) + .output(z.object({ ok: z.literal(true) })); + +export const customer = { + list: listCustomersContract, + get: getCustomerContract, + create: createCustomerContract, + update: updateCustomerContract, + delete: deleteCustomerContract, +}; diff --git a/src/contracts/order.ts b/src/contracts/order.ts new file mode 100644 index 0000000..6ccdf8d --- /dev/null +++ b/src/contracts/order.ts @@ -0,0 +1,36 @@ +import { oc } from "@orpc/contract"; +import { z } from "zod"; +import { CustomerSchema } from "../schemas/customer"; +import { OrderItemSchema, OrderSchema } from "../schemas/order"; +import { + PaginationInputSchema, + PaginationOutputSchema, +} from "../schemas/pagination"; + +// Order with related data for list and get views +const OrderWithRelationsSchema = OrderSchema.extend({ + customer: CustomerSchema.nullable(), + orderItems: z.array(OrderItemSchema), +}); + +const ListOrdersInputSchema = PaginationInputSchema.extend({ + customerId: z.string().optional(), + status: z.string().optional(), // Prisma uses String type for status +}); + +const ListOrdersOutputSchema = PaginationOutputSchema.extend({ + orders: z.array(OrderWithRelationsSchema), +}); + +export const listOrdersContract = oc + .input(ListOrdersInputSchema) + .output(ListOrdersOutputSchema); + +export const getOrderContract = oc + .input(z.object({ id: z.string() })) + .output(OrderWithRelationsSchema); + +export const order = { + list: listOrdersContract, + get: getOrderContract, +}; diff --git a/src/contracts/products.ts b/src/contracts/products.ts index faba3ea..d25359e 100644 --- a/src/contracts/products.ts +++ b/src/contracts/products.ts @@ -1,6 +1,7 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; import { CurrencySchema } from "../schemas/currency"; +import { ProductPriceInputSchema } from "../schemas/product-price-input"; export const ProductPriceSchema = z.object({ id: z.string(), @@ -31,6 +32,42 @@ export const listProductsContract = oc .input(z.object({}).optional()) .output(ListProductsOutputSchema); +// CRUD input schemas +const CreateProductInputSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + price: ProductPriceInputSchema, + userMetadata: z.record(z.string(), z.string()).optional(), +}); + +const UpdateProductInputSchema = z.object({ + id: z.string(), + name: z.string().min(1).optional(), + description: z.string().optional(), + price: ProductPriceInputSchema.optional(), + userMetadata: z.record(z.string(), z.string()).optional(), +}); + +export const getProductContract = oc + .input(z.object({ id: z.string() })) + .output(ProductSchema); + +export const createProductContract = oc + .input(CreateProductInputSchema) + .output(ProductSchema); + +export const updateProductContract = oc + .input(UpdateProductInputSchema) + .output(ProductSchema); + +export const deleteProductContract = oc + .input(z.object({ id: z.string() })) + .output(z.object({ ok: z.literal(true) })); + export const products = { list: listProductsContract, + get: getProductContract, + create: createProductContract, + update: updateProductContract, + delete: deleteProductContract, }; diff --git a/src/index.ts b/src/index.ts index affa6d5..5c63438 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { checkout } from "./contracts/checkout"; +import { customer } from "./contracts/customer"; import { onboarding } from "./contracts/onboarding"; +import { order } from "./contracts/order"; import { products } from "./contracts/products"; export type { @@ -28,7 +30,57 @@ export { ListProductsOutputSchema, } from "./contracts/products"; -export const contract = { checkout, onboarding, products }; +// New MCP schemas +export type { Customer } from "./schemas/customer"; +export { CustomerSchema } from "./schemas/customer"; +export type { Order, OrderItem, OrderStatus } from "./schemas/order"; +export { + OrderSchema, + OrderItemSchema, + OrderStatusSchema, +} from "./schemas/order"; +export type { PaginationInput, PaginationOutput } from "./schemas/pagination"; +export { + PaginationInputSchema, + PaginationOutputSchema, +} from "./schemas/pagination"; +export type { + ProductPriceInput, + RecurringIntervalInput, +} from "./schemas/product-price-input"; +export { + ProductPriceInputSchema, + RecurringIntervalInputSchema, +} from "./schemas/product-price-input"; + +// Unified contract - contains all methods from both SDK and MCP +export const contract = { checkout, customer, onboarding, order, products }; + +// SDK contract - only the methods the SDK router implements +export const sdkContract = { + checkout: { + get: checkout.get, + create: checkout.create, + confirm: checkout.confirm, + registerInvoice: checkout.registerInvoice, + paymentReceived: checkout.paymentReceived, + }, + onboarding, + products: { + list: products.list, + }, +}; + +// MCP contract - only the methods the MCP router implements +export const mcpContract = { + customer, + order, + checkout: { + list: checkout.listSummary, + get: checkout.getSummary, + }, + products, +}; export type { MetadataValidationError } from "./validation/metadata-validation"; export { diff --git a/src/schemas/customer.ts b/src/schemas/customer.ts new file mode 100644 index 0000000..5eb4dbc --- /dev/null +++ b/src/schemas/customer.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +/** + * Customer schema for MCP API responses. + * Represents a customer in the organization. + * Note: Uses modifiedAt to match Prisma schema naming. + */ +export const CustomerSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + email: z.string().nullable(), + emailVerified: z.boolean(), + externalId: z.string().nullable(), + userMetadata: z.record(z.string(), z.any()).nullable(), + organizationId: z.string(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), +}); + +export type Customer = z.infer; diff --git a/src/schemas/order.ts b/src/schemas/order.ts new file mode 100644 index 0000000..cf4cd2b --- /dev/null +++ b/src/schemas/order.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { CurrencySchema } from "./currency"; + +/** + * Order status enum matching Prisma OrderStatus. + * Note: Prisma uses String type, so we validate against known values. + */ +export const OrderStatusSchema = z.enum([ + "PENDING", + "PAID", + "REFUNDED", + "CANCELLED", +]); + +export type OrderStatus = z.infer; + +/** + * Order item schema representing a line item in an order. + * Note: Uses modifiedAt to match Prisma schema naming. + */ +export const OrderItemSchema = z.object({ + id: z.string(), + orderId: z.string(), + productPriceId: z.string().nullable(), + label: z.string(), + amount: z.number(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), +}); + +export type OrderItem = z.infer; + +/** + * Order schema for MCP API responses. + * Note: Uses modifiedAt to match Prisma schema naming. + * Note: Order doesn't have totalAmount directly - it's calculated from subtotalAmount + taxAmount. + */ +export const OrderSchema = z.object({ + id: z.string(), + organizationId: z.string(), + customerId: z.string().nullable(), + status: OrderStatusSchema, + currency: CurrencySchema, + subtotalAmount: z.number(), + taxAmount: z.number(), + userMetadata: z.record(z.string(), z.any()).nullable(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), +}); + +export type Order = z.infer; diff --git a/src/schemas/pagination.ts b/src/schemas/pagination.ts new file mode 100644 index 0000000..f9b9a8d --- /dev/null +++ b/src/schemas/pagination.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +/** + * Pagination input schema for list operations. + * Uses cursor-based pagination for efficient large dataset traversal. + */ +export const PaginationInputSchema = z.object({ + limit: z.number().int().min(1).max(100).default(50), + cursor: z.string().optional(), +}); + +export type PaginationInput = z.infer; + +/** + * Pagination output schema for list operations. + * Returns a cursor for the next page, or null if no more results. + */ +export const PaginationOutputSchema = z.object({ + nextCursor: z.string().nullable(), +}); + +export type PaginationOutput = z.infer; diff --git a/src/schemas/product-price-input.ts b/src/schemas/product-price-input.ts new file mode 100644 index 0000000..e4adda0 --- /dev/null +++ b/src/schemas/product-price-input.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { CurrencySchema } from "./currency"; + +/** + * COPIED from moneydevkit.com/lib/products/schema.ts - ProductPriceFormSchema + * TODO: When api-contract moves to monorepo, import from shared location instead of copying + * + * This schema is used for MCP product create/update operations. + * It mirrors the dashboard's pricing validation logic. + */ + +// Price amount types +export const PriceAmountTypeSchema = z.enum(["FIXED", "CUSTOM"]); +export type PriceAmountType = z.infer; + +/** + * Recurring interval schema for product INPUT (MCP create/update). + * Uses "NEVER" explicitly for one-time purchases. + * Server normalizes "NEVER" → null when storing/returning. + */ +export const RecurringIntervalInputSchema = z.enum([ + "NEVER", + "MONTH", + "QUARTER", + "YEAR", +]); +export type RecurringIntervalInput = z.infer< + typeof RecurringIntervalInputSchema +>; + +/** + * Simplified pricing schema: one price per product. + * Validation rules vary by amountType: + * - FIXED: priceAmount required and positive + * - CUSTOM: minimumAmount and presetAmount optional (both non-negative if provided) + */ +export const ProductPriceInputSchema = z + .object({ + recurringInterval: RecurringIntervalInputSchema, + currency: CurrencySchema, + amountType: PriceAmountTypeSchema, + // Required for FIXED, ignored for CUSTOM + priceAmount: z + .number() + .positive({ message: "Price must be greater than 0" }) + .optional(), + // Optional for CUSTOM: minimum amount customer can pay + minimumAmount: z + .number() + .nonnegative({ message: "Minimum amount cannot be negative" }) + .optional(), + // Optional for CUSTOM: suggested/default amount + presetAmount: z + .number() + .nonnegative({ message: "Preset amount cannot be negative" }) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.amountType === "FIXED") { + if ( + data.priceAmount === undefined || + data.priceAmount === null || + Number.isNaN(data.priceAmount) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Price must be set for fixed price products", + path: ["priceAmount"], + }); + } + } + // For CUSTOM: if both minimumAmount and presetAmount are set, preset should be >= minimum + if ( + data.amountType === "CUSTOM" && + data.minimumAmount !== undefined && + data.presetAmount !== undefined + ) { + if (data.presetAmount < data.minimumAmount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Preset amount must be at least the minimum amount", + path: ["presetAmount"], + }); + } + } + }); + +export type ProductPriceInput = z.infer;