UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

700 lines (648 loc) 16 kB
/** * @beignet/core/payments * * Provider-neutral payments primitives for Beignet applications. */ import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; /** * String metadata attached to provider-owned payment objects. */ export type PaymentMetadata = Record<string, string>; /** * Checkout modes understood by Beignet's provider-neutral payment port. */ export type PaymentCheckoutMode = "payment" | "subscription"; /** * Hosted checkout line item. */ export interface PaymentCheckoutLineItem { /** * Provider price identifier. */ priceId: string; /** * Quantity for this price. Defaults to the provider's default behavior. */ quantity?: number; } /** * Input for creating a hosted checkout session. */ export interface CreateCheckoutSessionInput { /** * One-time payment or subscription checkout. */ mode: PaymentCheckoutMode; /** * Line items to include in checkout. */ lineItems: readonly PaymentCheckoutLineItem[]; /** * URL the provider redirects to after successful checkout. */ successUrl: string; /** * URL the provider redirects to when checkout is canceled. */ cancelUrl: string; /** * Existing provider customer ID, when known. */ customerId?: string; /** * App-owned reference copied into the provider session. */ clientReferenceId?: string; /** * Provider metadata. */ metadata?: PaymentMetadata; /** * Provider idempotency key for this external call. */ idempotencyKey?: string; } /** * Hosted checkout session returned by a payment provider. */ export interface CheckoutSession { /** * Provider session ID. */ id: string; /** * Provider name. */ provider: string; /** * Checkout mode. */ mode: PaymentCheckoutMode; /** * Hosted checkout URL, when the provider returns one. */ url?: string; /** * Client secret, when the provider supports embedded checkout. */ clientSecret?: string; /** * Provider customer ID, when known. */ customerId?: string; /** * Provider status, when known. */ status?: string; /** * Provider metadata. */ metadata?: PaymentMetadata; /** * Raw provider response for app-owned escape hatches. */ raw?: unknown; } /** * Input for creating a billing portal session. */ export interface CreateBillingPortalSessionInput { /** * Provider customer ID. */ customerId: string; /** * URL the provider redirects to after leaving the portal. */ returnUrl: string; /** * Provider idempotency key for this external call. */ idempotencyKey?: string; } /** * Billing portal session returned by a payment provider. */ export interface BillingPortalSession { /** * Provider session ID. */ id: string; /** * Provider name. */ provider: string; /** * Hosted portal URL. */ url: string; /** * Provider customer ID. */ customerId: string; /** * Raw provider response for app-owned escape hatches. */ raw?: unknown; } /** * Refund reasons common to hosted payment providers. */ export type PaymentRefundReason = | "duplicate" | "fraudulent" | "requested_by_customer"; /** * Input for creating a refund. */ export interface CreateRefundInput { /** * Provider payment ID, such as a payment intent ID. */ paymentId: string; /** * Amount in the provider's smallest currency unit. Omit for a full refund. */ amount?: number; /** * Reason for the refund. */ reason?: PaymentRefundReason; /** * Provider metadata. */ metadata?: PaymentMetadata; /** * Provider idempotency key for this external call. */ idempotencyKey?: string; } /** * Refund returned by a payment provider. */ export interface Refund { /** * Provider refund ID. */ id: string; /** * Provider name. */ provider: string; /** * Provider payment ID that was refunded. */ paymentId: string; /** * Refunded amount in the provider's smallest currency unit, when known. */ amount?: number; /** * Currency code, when known. */ currency?: string; /** * Provider refund status, when known. */ status?: string; /** * Provider metadata. */ metadata?: PaymentMetadata; /** * Raw provider response for app-owned escape hatches. */ raw?: unknown; } /** * Raw webhook payload accepted by payment providers. */ export type PaymentWebhookRawBody = string | Uint8Array | ArrayBuffer; /** * Input for verifying and parsing a provider webhook. */ export interface VerifyPaymentWebhookInput { /** * Raw request body. Do not pass a parsed JSON body. */ rawBody: PaymentWebhookRawBody; /** * Provider signature header value. */ signature: string; } /** * Normalized provider webhook event. */ export interface PaymentWebhookEvent { /** * Provider event ID. */ id: string; /** * Provider event type, such as "checkout.session.completed". */ type: string; /** * Provider name. */ provider: string; /** * Event creation time, when known. */ createdAt?: Date; /** * Whether the event came from live mode, when the provider exposes it. */ livemode?: boolean; /** * Provider event data object. */ data: unknown; /** * Raw provider event for app-owned escape hatches. */ raw?: unknown; } /** * App-facing payments port. * * Implement this with hosted payment providers such as Stripe. Application * billing logic should depend on this interface instead of provider SDKs. */ export interface PaymentsPort { /** * Create a hosted checkout session. */ createCheckoutSession( input: CreateCheckoutSessionInput, ): Promise<CheckoutSession>; /** * Create a hosted billing portal session. */ createBillingPortalSession( input: CreateBillingPortalSessionInput, ): Promise<BillingPortalSession>; /** * Create a refund. */ createRefund(input: CreateRefundInput): Promise<Refund>; /** * Verify and parse a provider webhook. */ verifyWebhook(input: VerifyPaymentWebhookInput): Promise<PaymentWebhookEvent>; } /** * Error thrown by payment helpers and provider adapters. */ export class PaymentProviderError extends Error { /** * Provider name when known. */ readonly provider?: string; /** * Operation that failed. */ readonly operation: string; /** * Provider error code when known. */ readonly code?: string; /** * Original provider error when available. */ readonly cause?: unknown; constructor(args: { provider?: string; operation: string; message: string; code?: string; cause?: unknown; }) { super(args.message); this.name = "PaymentProviderError"; this.provider = args.provider; this.operation = args.operation; this.code = args.code; this.cause = args.cause; } } /** * Captured checkout session created by the memory payments adapter. */ export type MemoryCheckoutSession = CheckoutSession & { input: CreateCheckoutSessionInput; createdAt: Date; }; /** * Captured billing portal session created by the memory payments adapter. */ export type MemoryBillingPortalSession = BillingPortalSession & { input: CreateBillingPortalSessionInput; createdAt: Date; }; /** * Captured refund created by the memory payments adapter. */ export type MemoryRefund = Refund & { input: CreateRefundInput; createdAt: Date; }; /** * In-memory payments port for tests and local examples. */ export interface MemoryPaymentsPort extends PaymentsPort { /** * Captured checkout sessions. */ readonly checkoutSessions: readonly MemoryCheckoutSession[]; /** * Captured billing portal sessions. */ readonly billingPortalSessions: readonly MemoryBillingPortalSession[]; /** * Captured refunds. */ readonly refunds: readonly MemoryRefund[]; /** * Webhook events that were verified by the memory adapter. */ readonly webhookEvents: readonly PaymentWebhookEvent[]; /** * Queue a webhook event to be returned by the next `verifyWebhook(...)` call. */ queueWebhookEvent(event: PaymentWebhookEvent): void; /** * Clear captured state. */ clear(): void; } /** * Options for `createMemoryPayments(...)`. */ export interface CreateMemoryPaymentsOptions { /** * Clock used for captured payment objects. */ now?: () => Date; /** * ID factory used for captured payment objects. */ id?: (prefix: string) => string; /** * Observer called after a checkout session is created. */ onCheckoutSessionCreated?: ( session: MemoryCheckoutSession, ) => MaybePromise<void>; /** * Observer called after a billing portal session is created. */ onBillingPortalSessionCreated?: ( session: MemoryBillingPortalSession, ) => MaybePromise<void>; /** * Observer called after a refund is created. */ onRefundCreated?: (refund: MemoryRefund) => MaybePromise<void>; /** * Observer called after a webhook event is verified. */ onWebhookVerified?: (event: PaymentWebhookEvent) => MaybePromise<void>; } function defaultMemoryId(prefix: string): string { return `${prefix}_${crypto.randomUUID()}`; } function cloneMetadata( metadata: PaymentMetadata | undefined, ): PaymentMetadata | undefined { return metadata ? { ...metadata } : undefined; } function cloneCheckoutInput( input: CreateCheckoutSessionInput, ): CreateCheckoutSessionInput { return { ...input, lineItems: input.lineItems.map((item) => ({ ...item })), metadata: cloneMetadata(input.metadata), }; } function cloneRefundInput(input: CreateRefundInput): CreateRefundInput { return { ...input, metadata: cloneMetadata(input.metadata), }; } /** * Create an in-memory payments port for tests, local development, and * examples. * * The memory adapter does not contact a payment provider or validate webhook * signatures. Queue webhook events explicitly with `queueWebhookEvent(...)`. */ export function createMemoryPayments( options: CreateMemoryPaymentsOptions = {}, ): MemoryPaymentsPort { const checkoutSessions: MemoryCheckoutSession[] = []; const billingPortalSessions: MemoryBillingPortalSession[] = []; const refunds: MemoryRefund[] = []; const webhookEvents: PaymentWebhookEvent[] = []; const queuedWebhookEvents: PaymentWebhookEvent[] = []; const now = options.now ?? (() => new Date()); const id = options.id ?? defaultMemoryId; return { get checkoutSessions() { return checkoutSessions; }, get billingPortalSessions() { return billingPortalSessions; }, get refunds() { return refunds; }, get webhookEvents() { return webhookEvents; }, async createCheckoutSession(input) { const session: MemoryCheckoutSession = { id: id("checkout"), provider: "memory", mode: input.mode, url: `https://payments.example.test/checkout/${id("url")}`, customerId: input.customerId, metadata: cloneMetadata(input.metadata), input: cloneCheckoutInput(input), createdAt: now(), }; checkoutSessions.push(session); await options.onCheckoutSessionCreated?.(session); return session; }, async createBillingPortalSession(input) { const session: MemoryBillingPortalSession = { id: id("portal"), provider: "memory", url: `https://payments.example.test/portal/${id("url")}`, customerId: input.customerId, input: { ...input }, createdAt: now(), }; billingPortalSessions.push(session); await options.onBillingPortalSessionCreated?.(session); return session; }, async createRefund(input) { const refund: MemoryRefund = { id: id("refund"), provider: "memory", paymentId: input.paymentId, amount: input.amount, status: "succeeded", metadata: cloneMetadata(input.metadata), input: cloneRefundInput(input), createdAt: now(), }; refunds.push(refund); await options.onRefundCreated?.(refund); return refund; }, async verifyWebhook() { const event = queuedWebhookEvents.shift(); if (!event) { throw new PaymentProviderError({ provider: "memory", operation: "payments.verifyWebhook", message: "No memory payment webhook event is queued. Call queueWebhookEvent(...) before verifyWebhook(...).", }); } webhookEvents.push(event); await options.onWebhookVerified?.(event); return event; }, queueWebhookEvent(event) { queuedWebhookEvents.push(event); }, clear() { checkoutSessions.length = 0; billingPortalSessions.length = 0; refunds.length = 0; webhookEvents.length = 0; queuedWebhookEvents.length = 0; }, }; } /** * Options for the memory payments provider. */ export interface MemoryPaymentsProviderOptions extends CreateMemoryPaymentsOptions { /** * Provider name. Defaults to "memory-payments". */ name?: string; } /** * Ports contributed by the memory payments provider. */ export interface MemoryPaymentsProviderPorts { /** * Beignet payments port. */ payments: PaymentsPort; } /** * Create a provider that contributes an in-memory payments port. */ export function createMemoryPaymentsProvider( options: MemoryPaymentsProviderOptions = {}, ) { const { name = "memory-payments", onCheckoutSessionCreated, onBillingPortalSessionCreated, onRefundCreated, onWebhookVerified, ...paymentsOptions } = options; return createProvider({ name, metadata: { ports: ["payments"], watchers: ["payments"], }, setup({ ports }) { const instrumentation = createProviderInstrumentation(ports, { providerName: name, watcher: "payments", }); const payments: PaymentsPort = createMemoryPayments({ ...paymentsOptions, async onCheckoutSessionCreated(session) { instrumentation.custom({ name: "payments.checkout.created", label: "Checkout session created", summary: session.id, details: { id: session.id, mode: session.mode, customerId: session.customerId, }, }); await onCheckoutSessionCreated?.(session); }, async onBillingPortalSessionCreated(session) { instrumentation.custom({ name: "payments.portal.created", label: "Billing portal session created", summary: session.id, details: { id: session.id, customerId: session.customerId, }, }); await onBillingPortalSessionCreated?.(session); }, async onRefundCreated(refund) { instrumentation.custom({ name: "payments.refund.created", label: "Refund created", summary: refund.id, details: { id: refund.id, paymentId: refund.paymentId, amount: refund.amount, status: refund.status, }, }); await onRefundCreated?.(refund); }, async onWebhookVerified(event) { instrumentation.custom({ name: "payments.webhook.verified", label: "Payment webhook verified", summary: event.type, details: { id: event.id, eventType: event.type, livemode: event.livemode, }, }); await onWebhookVerified?.(event); }, }); return { ports: { payments, } satisfies MemoryPaymentsProviderPorts, }; }, }); }