@beignet/core
Version:
Core framework primitives for Beignet
700 lines (648 loc) • 16 kB
text/typescript
/**
* @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,
};
},
});
}