From 47706092a285da5c56771f6b6394c9ef83c6e02a Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Thu, 22 Jan 2026 09:20:19 -0500 Subject: [PATCH 1/9] feat: add subscription schemas and contracts Add subscription support for recurring payments: - SubscriptionSchema with status, period dates, and cancellation fields - SubscriptionWebhookPayloadSchema for subscription.* events - Subscription ORPC contracts (createRenewalCheckout, cancel, get) --- src/contracts/subscription.ts | 49 +++++++++++++++++++++++++++++++++ src/index.ts | 22 ++++++++++++++- src/schemas/subscription.ts | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/contracts/subscription.ts create mode 100644 src/schemas/subscription.ts diff --git a/src/contracts/subscription.ts b/src/contracts/subscription.ts new file mode 100644 index 0000000..a0af9e2 --- /dev/null +++ b/src/contracts/subscription.ts @@ -0,0 +1,49 @@ +import { oc } from "@orpc/contract"; +import { z } from "zod"; +import { SubscriptionSchema } from "../schemas/subscription"; + +export const CreateRenewalCheckoutInputSchema = z.object({ + subscriptionId: z.string(), +}); + +export const CreateRenewalCheckoutOutputSchema = z.object({ + checkoutId: z.string(), +}); + +export const CancelSubscriptionInputSchema = z.object({ + subscriptionId: z.string(), +}); + +export const CancelSubscriptionOutputSchema = z.object({ + ok: z.boolean(), +}); + +export const GetSubscriptionInputSchema = z.object({ + subscriptionId: z.string(), +}); + +export type CreateRenewalCheckout = z.infer< + typeof CreateRenewalCheckoutInputSchema +>; +export type CancelSubscriptionInput = z.infer< + typeof CancelSubscriptionInputSchema +>; +export type GetSubscriptionInput = z.infer; + +export const createRenewalCheckoutContract = oc + .input(CreateRenewalCheckoutInputSchema) + .output(CreateRenewalCheckoutOutputSchema); + +export const cancelSubscriptionContract = oc + .input(CancelSubscriptionInputSchema) + .output(CancelSubscriptionOutputSchema); + +export const getSubscriptionContract = oc + .input(GetSubscriptionInputSchema) + .output(SubscriptionSchema); + +export const subscription = { + createRenewalCheckout: createRenewalCheckoutContract, + cancel: cancelSubscriptionContract, + get: getSubscriptionContract, +}; diff --git a/src/index.ts b/src/index.ts index affa6d5..1705a65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { checkout } from "./contracts/checkout"; import { onboarding } from "./contracts/onboarding"; import { products } from "./contracts/products"; +import { subscription } from "./contracts/subscription"; export type { ConfirmCheckout, @@ -17,6 +18,11 @@ export type { StartDeviceAuth as StartDeviceAuthInput, StartDeviceAuthResponse, } from "./contracts/onboarding"; +export type { + CancelSubscriptionInput, + CreateRenewalCheckout, + GetSubscriptionInput, +} from "./contracts/subscription"; export type { Checkout } from "./schemas/checkout"; export { CheckoutSchema } from "./schemas/checkout"; export type { Currency } from "./schemas/currency"; @@ -27,8 +33,22 @@ export { ProductPriceSchema, ListProductsOutputSchema, } from "./contracts/products"; +export type { + RecurringInterval, + Subscription, + SubscriptionStatus, + SubscriptionWebhookEvent, + SubscriptionWebhookPayload, +} from "./schemas/subscription"; +export { + RecurringIntervalSchema, + SubscriptionSchema, + SubscriptionStatusSchema, + SubscriptionWebhookEventSchema, + SubscriptionWebhookPayloadSchema, +} from "./schemas/subscription"; -export const contract = { checkout, onboarding, products }; +export const contract = { checkout, onboarding, products, subscription }; export type { MetadataValidationError } from "./validation/metadata-validation"; export { diff --git a/src/schemas/subscription.ts b/src/schemas/subscription.ts new file mode 100644 index 0000000..ddb19da --- /dev/null +++ b/src/schemas/subscription.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { CurrencySchema } from "./currency"; + +export const SubscriptionStatusSchema = z.enum([ + "active", + "past_due", + "canceled", +]); + +export const RecurringIntervalSchema = z.enum(["MONTH", "QUARTER", "YEAR"]); + +export const SubscriptionSchema = z.object({ + id: z.string(), + customerId: z.string(), + customerEmail: z.string(), + productId: z.string(), + amount: z.number(), + currency: CurrencySchema, + recurringInterval: RecurringIntervalSchema, + status: SubscriptionStatusSchema, + currentPeriodStart: z.string(), // ISO date + currentPeriodEnd: z.string(), // ISO date + cancelAtPeriodEnd: z.boolean().optional(), + endsAt: z.string().optional(), // ISO date (if scheduled to end) + endedAt: z.string().optional(), // ISO date (if ended) + canceledAt: z.string().optional(), // ISO date (if canceled) + startedAt: z.string(), // ISO date +}); + +export const SubscriptionWebhookEventSchema = z.enum([ + "subscription.created", + "subscription.renewed", + "subscription.canceled", + "subscription.payment_failed", +]); + +export const SubscriptionWebhookPayloadSchema = z.object({ + handler: z.literal("webhooks"), + event: SubscriptionWebhookEventSchema, + subscription: SubscriptionSchema, +}); + +export type Subscription = z.infer; +export type SubscriptionStatus = z.infer; +export type RecurringInterval = z.infer; +export type SubscriptionWebhookEvent = z.infer< + typeof SubscriptionWebhookEventSchema +>; +export type SubscriptionWebhookPayload = z.infer< + typeof SubscriptionWebhookPayloadSchema +>; From 44e20820ffc0fd002d2c8befede258a5fa23af49 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 09:05:33 -0500 Subject: [PATCH 2/9] feat: add customer schemas and contracts Add customer endpoint support for subscription management: - CustomerSchema with subscriptions and hasActiveSubscription - CustomerSubscriptionSchema for subscription summaries - GetCustomerInputSchema with externalId/email/customerId lookup - customer.get ORPC contract --- src/contracts/customer.ts | 10 +++++++ src/index.ts | 19 +++++++++++- src/schemas/customer.ts | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/contracts/customer.ts create mode 100644 src/schemas/customer.ts diff --git a/src/contracts/customer.ts b/src/contracts/customer.ts new file mode 100644 index 0000000..fc5155b --- /dev/null +++ b/src/contracts/customer.ts @@ -0,0 +1,10 @@ +import { oc } from "@orpc/contract"; +import { CustomerSchema, GetCustomerInputSchema } from "../schemas/customer"; + +export const getCustomerContract = oc + .input(GetCustomerInputSchema) + .output(CustomerSchema); + +export const customer = { + get: getCustomerContract, +}; diff --git a/src/index.ts b/src/index.ts index 1705a65..a62b8c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { checkout } from "./contracts/checkout"; +import { customer } from "./contracts/customer"; import { onboarding } from "./contracts/onboarding"; import { products } from "./contracts/products"; import { subscription } from "./contracts/subscription"; @@ -23,6 +24,7 @@ export type { CreateRenewalCheckout, GetSubscriptionInput, } from "./contracts/subscription"; +export type { GetCustomerInput } from "./schemas/customer"; export type { Checkout } from "./schemas/checkout"; export { CheckoutSchema } from "./schemas/checkout"; export type { Currency } from "./schemas/currency"; @@ -47,8 +49,23 @@ export { SubscriptionWebhookEventSchema, SubscriptionWebhookPayloadSchema, } from "./schemas/subscription"; +export type { + Customer, + CustomerSubscription, +} from "./schemas/customer"; +export { + CustomerSchema, + CustomerSubscriptionSchema, + GetCustomerInputSchema, +} from "./schemas/customer"; -export const contract = { checkout, onboarding, products, subscription }; +export const contract = { + checkout, + customer, + onboarding, + products, + subscription, +}; 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..0e3ddcb --- /dev/null +++ b/src/schemas/customer.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { CurrencySchema } from "./currency"; +import { + RecurringIntervalSchema, + SubscriptionStatusSchema, +} from "./subscription"; + +/** + * Summary of a subscription for the customer response. + * Contains the essential fields needed for displaying subscription status. + */ +export const CustomerSubscriptionSchema = z.object({ + id: z.string(), + productId: z.string(), + status: SubscriptionStatusSchema, + currentPeriodStart: z.string(), // ISO date + currentPeriodEnd: z.string(), // ISO date + cancelAtPeriodEnd: z.boolean().optional(), + amount: z.number(), + currency: CurrencySchema, + recurringInterval: RecurringIntervalSchema, +}); + +/** + * Customer data with their subscriptions. + * Returned by the customer.get endpoint. + */ +export const CustomerSchema = z.object({ + id: z.string(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + externalId: z.string().nullable().optional(), + subscriptions: z.array(CustomerSubscriptionSchema), + hasActiveSubscription: z.boolean(), +}); + +/** + * Input for getting a customer. + * Requires exactly one of: externalId, email, or customerId. + */ +export const GetCustomerInputSchema = z + .object({ + externalId: z.string().optional(), + email: z.string().optional(), + customerId: z.string().optional(), + }) + .refine( + (data) => { + const fields = [data.externalId, data.email, data.customerId].filter( + Boolean, + ); + return fields.length === 1; + }, + { + message: + "Exactly one of externalId, email, or customerId must be provided", + }, + ); + +export type CustomerSubscription = z.infer; +export type Customer = z.infer; +export type GetCustomerInput = z.infer; From e58dddead8c86d0b1db4b529e3fd7cd89a6c04cf Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 09:23:57 -0500 Subject: [PATCH 3/9] fix: require email in CustomerSchema for subscription communication --- src/schemas/customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/customer.ts b/src/schemas/customer.ts index 0e3ddcb..071414d 100644 --- a/src/schemas/customer.ts +++ b/src/schemas/customer.ts @@ -27,7 +27,7 @@ export const CustomerSubscriptionSchema = z.object({ */ export const CustomerSchema = z.object({ id: z.string(), - email: z.string().nullable().optional(), + email: z.string(), name: z.string().nullable().optional(), externalId: z.string().nullable().optional(), subscriptions: z.array(CustomerSubscriptionSchema), From 97bafff0702e5b55130c5f37bd1560c4c32b1543 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 09:57:55 -0500 Subject: [PATCH 4/9] feat: add prepare script for git installs --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index adc15b8..c6b7e5d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "scripts": { "build": "tsup", + "prepare": "pnpm run build", "test": "vitest", "check": "biome check ./src --fix" }, From 3d8ca7c5da1dd8fdc5b18d640f39d5d843812318 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 10:01:58 -0500 Subject: [PATCH 5/9] fix: allow nullable email in Customer schema --- src/schemas/customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/customer.ts b/src/schemas/customer.ts index 071414d..0e3ddcb 100644 --- a/src/schemas/customer.ts +++ b/src/schemas/customer.ts @@ -27,7 +27,7 @@ export const CustomerSubscriptionSchema = z.object({ */ export const CustomerSchema = z.object({ id: z.string(), - email: z.string(), + email: z.string().nullable().optional(), name: z.string().nullable().optional(), externalId: z.string().nullable().optional(), subscriptions: z.array(CustomerSubscriptionSchema), From f22d5deeb4a90c7f73d8e281e8218fba6fbf4acb Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 15:05:37 -0500 Subject: [PATCH 6/9] fix: add datetime validation to ISO date fields, remove hasActiveSubscription - Add z.string().datetime() validation to currentPeriodStart, currentPeriodEnd in SubscriptionSchema and CustomerSubscriptionSchema - Remove hasActiveSubscription from CustomerSchema as it can be derived from subscriptions array client-side --- src/schemas/customer.ts | 5 ++--- src/schemas/subscription.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/schemas/customer.ts b/src/schemas/customer.ts index 0e3ddcb..81bceda 100644 --- a/src/schemas/customer.ts +++ b/src/schemas/customer.ts @@ -13,8 +13,8 @@ export const CustomerSubscriptionSchema = z.object({ id: z.string(), productId: z.string(), status: SubscriptionStatusSchema, - currentPeriodStart: z.string(), // ISO date - currentPeriodEnd: z.string(), // ISO date + currentPeriodStart: z.string().datetime(), + currentPeriodEnd: z.string().datetime(), cancelAtPeriodEnd: z.boolean().optional(), amount: z.number(), currency: CurrencySchema, @@ -31,7 +31,6 @@ export const CustomerSchema = z.object({ name: z.string().nullable().optional(), externalId: z.string().nullable().optional(), subscriptions: z.array(CustomerSubscriptionSchema), - hasActiveSubscription: z.boolean(), }); /** diff --git a/src/schemas/subscription.ts b/src/schemas/subscription.ts index ddb19da..146fac5 100644 --- a/src/schemas/subscription.ts +++ b/src/schemas/subscription.ts @@ -18,13 +18,13 @@ export const SubscriptionSchema = z.object({ currency: CurrencySchema, recurringInterval: RecurringIntervalSchema, status: SubscriptionStatusSchema, - currentPeriodStart: z.string(), // ISO date - currentPeriodEnd: z.string(), // ISO date + currentPeriodStart: z.string().datetime(), + currentPeriodEnd: z.string().datetime(), cancelAtPeriodEnd: z.boolean().optional(), - endsAt: z.string().optional(), // ISO date (if scheduled to end) - endedAt: z.string().optional(), // ISO date (if ended) - canceledAt: z.string().optional(), // ISO date (if canceled) - startedAt: z.string(), // ISO date + endsAt: z.string().datetime().optional(), + endedAt: z.string().datetime().optional(), + canceledAt: z.string().datetime().optional(), + startedAt: z.string().datetime(), }); export const SubscriptionWebhookEventSchema = z.enum([ From 631f90cc69818e9f89b8372a6730761238f39687 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 15:10:53 -0500 Subject: [PATCH 7/9] fix: make customerEmail nullable in SubscriptionSchema Allows the schema to handle edge cases while business logic enforces email requirement at subscription creation time. --- src/schemas/subscription.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/subscription.ts b/src/schemas/subscription.ts index 146fac5..c97d7c1 100644 --- a/src/schemas/subscription.ts +++ b/src/schemas/subscription.ts @@ -12,7 +12,7 @@ export const RecurringIntervalSchema = z.enum(["MONTH", "QUARTER", "YEAR"]); export const SubscriptionSchema = z.object({ id: z.string(), customerId: z.string(), - customerEmail: z.string(), + customerEmail: z.string().nullable(), productId: z.string(), amount: z.number(), currency: CurrencySchema, From 919e292c9d507f2666df05bf6f3a6705c6347e19 Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 15:20:22 -0500 Subject: [PATCH 8/9] fix: format code with biome --- src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2b04822..c1a92ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,14 @@ export { } from "./schemas/product-price-input"; // Unified contract - contains all methods from both SDK and MCP -export const contract = { checkout, customer, onboarding, order, products, subscription }; +export const contract = { + checkout, + customer, + onboarding, + order, + products, + subscription, +}; // SDK contract - only the methods the SDK router implements export const sdkContract = { From fa94a4cd8b85d8619a1327f857237252a699768e Mon Sep 17 00:00:00 2001 From: Nat Elkins Date: Tue, 27 Jan 2026 15:26:05 -0500 Subject: [PATCH 9/9] fix: add customer to sdkContract --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index c1a92ca..dda2775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,9 @@ export const sdkContract = { registerInvoice: checkout.registerInvoice, paymentReceived: checkout.paymentReceived, }, + customer: { + get: customer.getSdk, + }, onboarding, products: { list: products.list,