diff --git a/.gitignore b/.gitignore index a1dbe4b..3504f38 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ examples/*/dist/ *.tsbuildinfo coverage/ .DS_Store +fix.md diff --git a/src/index.ts b/src/index.ts index f5235c0..f6e5297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -380,6 +380,27 @@ export type { WebhookPayload, WebhookEventDetails, } from "./transaction/webhooks"; + +// ─── Payment notifications (fix.md) ─────────────────────────────────────────── +export { + registerPaymentWebhook, + unregisterPaymentWebhook, + listPaymentWebhooks, + clearPaymentWebhooks, + triggerPaymentNotifications, + dispatchPaymentNotification, + generatePaymentEventId, + isPaymentNotificationEvent, + PAYMENT_NOTIFICATION_EVENTS, +} from "./transaction/paymentNotifications"; +export type { + PaymentNotificationEvent, + PaymentWebhookOptions, + PaymentWebhookPayload, + PaymentWebhookRegistration, + PaymentNotificationInput, + PaymentNotificationChannel, +} from "./transaction/paymentNotifications"; export { DEFAULT_PRICE_CACHE_TTL_MS, exportTransactionHistory, diff --git a/src/tests/paymentNotifications.test.ts b/src/tests/paymentNotifications.test.ts new file mode 100644 index 0000000..5cf1ef7 --- /dev/null +++ b/src/tests/paymentNotifications.test.ts @@ -0,0 +1,427 @@ +/** + * Tests for the payment notification subsystem (fix.md). + * + * Covers successful delivery, retries (exponential backoff), configurable + * timeouts, duplicate-delivery identification through event IDs, failed + * endpoints surfacing errors, and decoupled notification channels. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + registerPaymentWebhook, + unregisterPaymentWebhook, + listPaymentWebhooks, + clearPaymentWebhooks, + triggerPaymentNotifications, + dispatchPaymentNotification, + generatePaymentEventId, + isPaymentNotificationEvent, + PAYMENT_NOTIFICATION_EVENTS, + type PaymentNotificationChannel, + type PaymentNotificationEvent, +} from "../transaction/paymentNotifications"; + +const SECRET_URL = "https://pay.example.com/webhook"; + +function okResponse(): Response { + return { ok: true, status: 200, statusText: "OK" } as Response; +} + +describe("paymentNotifications", () => { + beforeEach(() => { + clearPaymentWebhooks(); + }); + + afterEach(() => { + clearPaymentWebhooks(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + describe("event types", () => { + it("defines the supported payment events", () => { + expect(PAYMENT_NOTIFICATION_EVENTS).toEqual([ + "payment_received", + "payment_sent", + "payment_failed", + "payment_confirmed", + ]); + }); + + it("guards payment event types", () => { + expect(isPaymentNotificationEvent("payment_received")).toBe(true); + expect(isPaymentNotificationEvent("payment_confirmed")).toBe(true); + expect(isPaymentNotificationEvent("tx_confirmed")).toBe(false); + expect(isPaymentNotificationEvent("payment_failed")).toBe(true); + }); + }); + + describe("registerPaymentWebhook", () => { + it("registers a URL for multiple payment events", () => { + const result = registerPaymentWebhook(SECRET_URL, [ + "payment_received", + "payment_sent", + ]); + expect(result.status).toBe("ok"); + expect(listPaymentWebhooks("payment_received")).toHaveLength(1); + expect(listPaymentWebhooks("payment_sent")).toHaveLength(1); + expect(listPaymentWebhooks("payment_failed")).toHaveLength(0); + }); + + it("rejects an empty events array", () => { + const result = registerPaymentWebhook(SECRET_URL, []); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an invalid event type", () => { + const result = registerPaymentWebhook(SECRET_URL, [ + "payment_invalid" as PaymentNotificationEvent, + ]); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + expect(listPaymentWebhooks("payment_received")).toHaveLength(0); + }); + + it("rejects an invalid URL", () => { + const result = registerPaymentWebhook("not-a-url", ["payment_received"]); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an invalid maxRetries", () => { + const result = registerPaymentWebhook(SECRET_URL, ["payment_received"], { + maxRetries: -1, + }); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an invalid timeoutMs", () => { + const result = registerPaymentWebhook(SECRET_URL, ["payment_received"], { + timeoutMs: 0, + }); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("does not duplicate registrations for repeated events", () => { + const result = registerPaymentWebhook(SECRET_URL, [ + "payment_received", + "payment_received", + ]); + expect(result.status).toBe("ok"); + expect(listPaymentWebhooks("payment_received")).toHaveLength(1); + }); + + it("unregisters a specific event", () => { + registerPaymentWebhook(SECRET_URL, ["payment_received", "payment_sent"]); + const result = unregisterPaymentWebhook(SECRET_URL, "payment_received"); + expect(result.status).toBe("ok"); + expect(listPaymentWebhooks("payment_received")).toHaveLength(0); + expect(listPaymentWebhooks("payment_sent")).toHaveLength(1); + }); + + it("fails to unregister a nonexistent webhook", () => { + const result = unregisterPaymentWebhook(SECRET_URL, "payment_received"); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + }); + + describe("event IDs (idempotency)", () => { + it("derives the same event ID for the same payment identity", () => { + const a = generatePaymentEventId("tx-1", "payment_received", "GA001"); + const b = generatePaymentEventId("tx-1", "payment_received", "GA001"); + expect(a).toBe(b); + }); + + it("derives distinct event IDs for distinct payments", () => { + const a = generatePaymentEventId("tx-1", "payment_received", "GA001"); + const b = generatePaymentEventId("tx-2", "payment_received", "GA001"); + const c = generatePaymentEventId("tx-1", "payment_sent", "GA001"); + expect(a).not.toBe(b); + expect(a).not.toBe(c); + }); + + it("delivers identical event ID on duplicate deliveries", async () => { + global.fetch = vi.fn(() => Promise.resolve(okResponse())); + registerPaymentWebhook(SECRET_URL, ["payment_received"]); + + const input = { + transactionId: "tx-dup", + account: "GA001", + }; + await triggerPaymentNotifications("payment_received", input); + await triggerPaymentNotifications("payment_received", input); + + const fetchMock = global.fetch as ReturnType; + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body); + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(firstBody.eventId).toBe(secondBody.eventId); + expect(firstBody.eventId).toBe( + generatePaymentEventId("tx-dup", "payment_received", "GA001"), + ); + }); + + it("sends the event ID as an idempotency header", async () => { + global.fetch = vi.fn(() => Promise.resolve(okResponse())); + registerPaymentWebhook(SECRET_URL, ["payment_received"]); + + await triggerPaymentNotifications("payment_received", { + transactionId: "tx-h", + account: "GA001", + }); + + const [, init] = (global.fetch as ReturnType) + .mock.calls[0] as [string, { headers: Record; body: string }]; + const payload = JSON.parse(init.body); + expect(init.headers["Idempotency-Key"]).toBe(payload.eventId); + expect(init.headers["X-Sorokit-Event-Id"]).toBe(payload.eventId); + }); + }); + + describe("payload shape", () => { + it("includes event ID, timestamp, transaction ID, account, and event type", async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())); + global.fetch = fetchMock as typeof fetch; + registerPaymentWebhook(SECRET_URL, ["payment_confirmed"]); + + const results = await triggerPaymentNotifications("payment_confirmed", { + transactionId: "tx-payload", + account: "GA100", + }); + expect(results).toHaveLength(1); + expect(results[0].status).toBe("ok"); + + const [, init] = fetchMock.mock.calls[0] as [string, { body: string }]; + const payload = JSON.parse(init.body); + expect(typeof payload.eventId).toBe("string"); + expect(payload.event).toBe("payment_confirmed"); + expect(typeof payload.timestamp).toBe("string"); + expect(new Date(payload.timestamp).toString()).not.toBe("Invalid Date"); + expect(payload.transactionId).toBe("tx-payload"); + expect(payload.account).toBe("GA100"); + }); + }); + + describe("delivery", () => { + it("successfully delivers to a healthy endpoint", async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())); + global.fetch = fetchMock as typeof fetch; + registerPaymentWebhook(SECRET_URL, ["payment_received"]); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-ok", + account: "GA001", + }); + expect(results).toHaveLength(1); + expect(results[0].status).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not deliver to endpoints not subscribed to the event", async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())); + global.fetch = fetchMock as typeof fetch; + registerPaymentWebhook(SECRET_URL, ["payment_received"]); + + const results = await triggerPaymentNotifications("payment_sent", { + transactionId: "tx-no", + account: "GA001", + }); + expect(results).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("retries (exponential backoff)", () => { + it("retries a transient failure then succeeds", async () => { + let attempts = 0; + global.fetch = vi.fn(() => { + attempts++; + if (attempts < 3) return Promise.reject(new Error("flaky")); + return Promise.resolve(okResponse()); + }); + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + maxRetries: 3, + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-retry", + account: "GA001", + }); + expect(results[0].status).toBe("ok"); + expect(attempts).toBe(3); + }); + + it("gives up after exhausting configured attempts", async () => { + global.fetch = vi.fn(() => Promise.reject(new Error("down"))); + registerPaymentWebhook(SECRET_URL, ["payment_failed"], { + maxRetries: 2, + }); + + const results = await triggerPaymentNotifications("payment_failed", { + transactionId: "tx-fail", + account: "GA001", + }); + expect(results[0].status).toBe("error"); + expect(results[0].error?.code).toBe("NETWORK_ERROR"); + expect((global.fetch as ReturnType)).toHaveBeenCalledTimes(3); // 1 + 2 retries + }, 20000); + }); + + describe("timeout", () => { + it("treats a slow endpoint as a failed attempt", async () => { + const fetchMock = vi.fn( + () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("timed out")), 50); + }), + ); + global.fetch = fetchMock as typeof fetch; + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + maxRetries: 0, + timeoutMs: 5, + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-timeout", + account: "GA001", + }); + expect(results[0].status).toBe("error"); + expect((global.fetch as ReturnType)).toHaveBeenCalledTimes(1); + }, 20000); + }); + + describe("failed endpoints", () => { + it("surfaces an HTTP error for a failing endpoint", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ ok: false, status: 500, statusText: "Internal" } as Response), + ); + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + maxRetries: 0, + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-500", + account: "GA001", + }); + expect(results[0].status).toBe("error"); + expect(results[0].error?.code).toBe("NETWORK_ERROR"); + }); + + it("reports one failing endpoint without masking another", async () => { + const failingUrl = "https://fail.example.com/webhook"; + const goodUrl = "https://good.example.com/webhook"; + global.fetch = vi.fn((url: string) => + Promise.resolve( + url === failingUrl + ? ({ ok: false, status: 500 } as Response) + : okResponse(), + ), + ); + registerPaymentWebhook(failingUrl, ["payment_received"], { + maxRetries: 0, + }); + registerPaymentWebhook(goodUrl, ["payment_received"], { + maxRetries: 0, + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-two", + account: "GA001", + }); + expect(results).toHaveLength(2); + expect(results.some((r) => r.status === "error")).toBe(true); + expect(results.some((r) => r.status === "ok")).toBe(true); + }); + }); + + describe("notification channels (decoupled)", () => { + it("delivers to registered channels alongside webhooks", async () => { + global.fetch = vi.fn(() => Promise.resolve(okResponse())); + const channel: PaymentNotificationChannel = { + name: "test-channel", + deliver: vi.fn(() => Promise.resolve(true)), + }; + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + channels: [channel], + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-channel", + account: "GA001", + }); + expect(results).toHaveLength(2); + expect(results.every((r) => r.status === "ok")).toBe(true); + expect(channel.deliver).toHaveBeenCalledTimes(1); + }); + + it("surfaces channel failures without coupling to webhook logic", async () => { + global.fetch = vi.fn(() => Promise.resolve(okResponse())); + const channel: PaymentNotificationChannel = { + name: "failing-channel", + deliver: vi.fn(() => Promise.resolve(false)), + }; + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + channels: [channel], + }); + + const results = await triggerPaymentNotifications("payment_received", { + transactionId: "tx-chanfail", + account: "GA001", + }); + expect(results).toHaveLength(2); + expect(results[0].status).toBe("ok"); // webhook ok + expect(results[1].status).toBe("error"); // channel rejected + expect(results[1].error?.code).toBe("NETWORK_ERROR"); + }); + }); + + describe("dispatchPaymentNotification", () => { + it("returns immediately without blocking on delivery", async () => { + let resolveFetch: (value: Response) => void = () => undefined; + global.fetch = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + registerPaymentWebhook(SECRET_URL, ["payment_received"]); + + const before = Date.now(); + dispatchPaymentNotification("payment_received", { + transactionId: "tx-fast", + account: "GA001", + }); + const elapsed = Date.now() - before; + expect(elapsed).toBeLessThan(100); + resolveFetch(okResponse()); + }); + + it("swallows delivery failures without unhandled rejections", async () => { + global.fetch = vi.fn(() => Promise.reject(new Error("down"))); + registerPaymentWebhook(SECRET_URL, ["payment_received"], { + maxRetries: 0, + }); + + expect(() => + dispatchPaymentNotification("payment_received", { + transactionId: "tx-swallow", + account: "GA001", + }), + ).not.toThrow(); + await new Promise((r) => setTimeout(r, 10)); + }, 20000); + + it("is a no-op when nothing is subscribed", () => { + const fetchMock = vi.fn(); + global.fetch = fetchMock as typeof fetch; + dispatchPaymentNotification("payment_received", { + transactionId: "tx-noop", + account: "GA001", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/transaction/index.ts b/src/transaction/index.ts index fbfcfce..f68ac5c 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -291,6 +291,27 @@ export { dispatchTransactionEvent, verifySignature, } from "./webhooks"; + +// ─── Payment notifications (#fix.md) ────────────────────────────────────────── +export { + registerPaymentWebhook, + unregisterPaymentWebhook, + listPaymentWebhooks, + clearPaymentWebhooks, + triggerPaymentNotifications, + dispatchPaymentNotification, + generatePaymentEventId, + isPaymentNotificationEvent, + PAYMENT_NOTIFICATION_EVENTS, +} from "./paymentNotifications"; +export type { + PaymentNotificationEvent, + PaymentWebhookOptions, + PaymentWebhookPayload, + PaymentWebhookRegistration, + PaymentNotificationInput, + PaymentNotificationChannel, +} from "./paymentNotifications"; export type { WebhookEventType, TransactionWebhookEvent, diff --git a/src/transaction/paymentNotifications.ts b/src/transaction/paymentNotifications.ts new file mode 100644 index 0000000..c0c5789 --- /dev/null +++ b/src/transaction/paymentNotifications.ts @@ -0,0 +1,488 @@ +/** + * Payment notification subsystem. + * + * Lets applications subscribe a webhook endpoint (or an optional, decoupled + * notification channel) to payment lifecycle events such as payment_received, + * payment_sent, payment_failed, and payment_confirmed. + * + * Design goals (see fix.md): + * - Transport-agnostic delivery: the payload contract and dispatch logic do + * not depend on any particular HTTP client or channel implementation. + * - Non-blocking: dispatch never blocks (or throws into) the transaction + * processing path. + * - Bounded retries: exponential backoff with a hard cap and a configurable + * attempt limit so retry behavior cannot become an uncontrolled loop. + * - Idempotency: every payload carries a deterministic event ID derived from + * the payment identity so consumers can safely discard duplicate + * deliveries. + * - Clean error surfacing: registration and delivery failures are returned as + * structured `SorokitResult` errors. + */ + +import { ok, err, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; +import { sleep } from "../shared/utils"; + +/** + * Canonical payment lifecycle event types. + */ +export type PaymentNotificationEvent = + | "payment_received" + | "payment_sent" + | "payment_failed" + | "payment_confirmed"; + +/** + * The full list of supported payment event types. + */ +export const PAYMENT_NOTIFICATION_EVENTS: readonly PaymentNotificationEvent[] = [ + "payment_received", + "payment_sent", + "payment_failed", + "payment_confirmed", +]; + +/** Type guard for {@link PaymentNotificationEvent}. */ +export function isPaymentNotificationEvent( + value: unknown, +): value is PaymentNotificationEvent { + return ( + typeof value === "string" && + (PAYMENT_NOTIFICATION_EVENTS as readonly string[]).includes(value) + ); +} + +/** + * Delivery options for a single webhook subscription. + */ +export interface PaymentWebhookOptions { + /** Maximum number of retry attempts after the initial request (default: 3). */ + maxRetries?: number; + /** Per-attempt request timeout in milliseconds (default: 10_000). */ + timeoutMs?: number; + /** + * Optional list of notification channels to deliver to in addition to the + * HTTP webhook. Channels are an independently decoupled abstraction so + * additional transports (Slack, SMS, email, …) can be plugged in without + * coupling them to the webhook delivery logic. + */ + channels?: PaymentNotificationChannel[]; +} + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_BACKOFF_MS = 1000; +const MAX_BACKOFF_MS = 8000; + +/** + * Webhook payload delivered to a subscriber. + * + * Standardized so consumers can safely process duplicate deliveries: the + * `eventId` is deterministic for a given (account, event, transactionId), so + * redelivery of the same logical event is always identifiable. + */ +export interface PaymentWebhookPayload { + /** Deterministic event identifier for idempotency / duplicate detection. */ + eventId: string; + /** The payment lifecycle event that occurred. */ + event: PaymentNotificationEvent; + /** ISO-8601 timestamp of when the event was emitted. */ + timestamp: string; + /** The transaction this payment event relates to. */ + transactionId: string; + /** The account associated with the payment. */ + account: string; +} + +/** Input describing an emitted payment event. */ +export interface PaymentNotificationInput { + /** Transaction identifier the payment belongs to. */ + transactionId: string; + /** Account associated with the payment. */ + account: string; + /** + * Optional explicit event ID. When omitted, one is derived deterministically + * from the event identity so duplicate deliveries share the same ID. + */ + eventId?: string; +} + +/** + * A registered webhook subscription. + */ +export interface PaymentWebhookRegistration { + /** URL to deliver webhook payloads to. */ + url: string; + /** Payment events this subscription is interested in. */ + events: PaymentNotificationEvent[]; + /** Resolved delivery options. */ + options: Required>; +} + +/** + * Decoupled notification channel abstraction. + * + * Applications may register channels to receive the same payload as webhook + * subscribers. Implementing this interface is entirely independent of the + * webhook delivery machinery, satisfying the "without coupling them to + * webhook logic" requirement. + */ +export interface PaymentNotificationChannel { + /** Human-readable channel name, used in error messages. */ + readonly name: string; + /** + * Deliver a payload through this channel. + * Return `true` on success. Returning `false` (or throwing) counts as a + * failed attempt and is retried like a failed webhook delivery. + */ + deliver(payload: PaymentWebhookPayload): Promise; +} + +const webhookRegistry = new Map(); +const channelRegistry = new Set(); + +/** Registry key — one entry per (url, event) pair. */ +function registrationKey( + url: string, + event: PaymentNotificationEvent, +): string { + return `${url}::${event}`; +} + +function validateUrl(url: string): SorokitResult | undefined { + if (typeof url !== "string" || url.length === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Webhook URL must be a non-empty string", + ); + } + try { + new URL(url); + } catch { + return err(SorokitErrorCode.INVALID_CONFIG, `Invalid webhook URL: ${url}`); + } + return undefined; +} + +function isNonNegativeInteger(value: number, name: string): boolean { + return Number.isInteger(value) && value >= 0; +} + +function validateOptions( + options: PaymentWebhookOptions | undefined, +): SorokitResult>> | undefined { + if (options === undefined) { + return ok({ + maxRetries: DEFAULT_MAX_RETRIES, + timeoutMs: DEFAULT_TIMEOUT_MS, + }); + } + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!isNonNegativeInteger(maxRetries, "maxRetries")) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "maxRetries must be a non-negative integer", + ); + } + if (!isNonNegativeInteger(timeoutMs, "timeoutMs") || timeoutMs === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "timeoutMs must be a positive integer", + ); + } + return ok({ maxRetries, timeoutMs }); +} + +/** + * Register a webhook (and optional notification channels) for one or more + * payment events. + * + * @param url - URL to deliver webhook payloads to. + * @param events - Payment event types to subscribe to. + * @param options - Delivery options (retries, timeout, extra channels). + * @returns ok(void) on success, or a structured error on invalid input. + * + * @example + * const result = registerPaymentWebhook( + * "https://example.com/payments", + * ["payment_received", "payment_failed"], + * { maxRetries: 5, timeoutMs: 5000 }, + * ); + */ +export function registerPaymentWebhook( + url: string, + events: PaymentNotificationEvent[], + options?: PaymentWebhookOptions, +): SorokitResult { + if (!Array.isArray(events) || events.length === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "At least one payment event type must be provided", + ); + } + + const normalized: PaymentNotificationEvent[] = []; + for (const event of events) { + if (!isPaymentNotificationEvent(event)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Invalid payment event type: ${String(event)}. Must be one of: ${PAYMENT_NOTIFICATION_EVENTS.join(", ")}`, + ); + } + normalized.push(event); + } + + const urlError = validateUrl(url); + if (urlError) return urlError; + + const optionsResult = validateOptions(options); + if (optionsResult && optionsResult.status === "error") return optionsResult; + const resolvedOptions = optionsResult!.data; + + for (const channel of options?.channels ?? []) { + if (!channel || typeof channel.deliver !== "function") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Notification channels must implement deliver(payload)", + ); + } + channelRegistry.add(channel); + } + + const registration: PaymentWebhookRegistration = { + url, + events: normalized, + options: resolvedOptions, + }; + + for (const event of normalized) { + webhookRegistry.set(registrationKey(url, event), registration); + } + + return ok(undefined); +} + +/** + * Remove a webhook subscription for a specific event. + */ +export function unregisterPaymentWebhook( + url: string, + event: PaymentNotificationEvent, +): SorokitResult { + if (!isPaymentNotificationEvent(event)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Invalid payment event type: ${String(event)}`, + ); + } + const key = registrationKey(url, event); + if (!webhookRegistry.has(key)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `No webhook registered for event '${event}' at '${url}'`, + ); + } + webhookRegistry.delete(key); + return ok(undefined); +} + +/** + * List all webhook subscriptions for a payment event type. + */ +export function listPaymentWebhooks( + event: PaymentNotificationEvent, +): PaymentWebhookRegistration[] { + if (!isPaymentNotificationEvent(event)) return []; + const results: PaymentWebhookRegistration[] = []; + for (const [key, registration] of webhookRegistry.entries()) { + if (key.endsWith(`::${event}`) && !results.includes(registration)) { + results.push(registration); + } + } + return results; +} + +/** Clear all registered webhook subscriptions. */ +export function clearPaymentWebhooks(): void { + webhookRegistry.clear(); + channelRegistry.clear(); +} + +/** + * Deterministically derive an event ID from the payment identity so duplicate + * deliveries of the same logical event carry the same identifier. + * + * FNV-1a (32-bit) over `transactionId | event | account`, hex-encoded. + */ +export function generatePaymentEventId( + transactionId: string, + event: PaymentNotificationEvent, + account: string, +): string { + const data = `${transactionId}|${event}|${account}`; + let hash = 0x811c9dc5; + for (let i = 0; i < data.length; i++) { + hash ^= data.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +/** Resolve the event ID, applying caller-supplied override or the derived one. */ +function resolveEventId( + input: PaymentNotificationInput, + event: PaymentNotificationEvent, +): string { + return input.eventId ?? generatePaymentEventId(input.transactionId, event, input.account); +} + +/** + * Send a webhook payload with exponential backoff and a configurable retry + * limit and timeout. + * + * @param registration - The subscription to deliver to. + * @param payload - Payload to deliver. + * @returns ok(void) on success, or a structured error after the final attempt. + */ +async function sendPayloadWithRetry( + registration: PaymentWebhookRegistration, + payload: PaymentWebhookPayload, +): Promise> { + const { url } = registration; + const { maxRetries, timeoutMs } = registration.options; + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Sorokit-Event": payload.event, + "X-Sorokit-Event-Id": payload.eventId, + "Idempotency-Key": payload.eventId, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return ok(undefined); + } catch (error) { + lastError = error; + if (attempt < maxRetries) { + // Exponential backoff (1s, 2s, 4s, …) capped at MAX_BACKOFF_MS so + // retry behavior can never spin out of control. + const delayMs = Math.min( + DEFAULT_BACKOFF_MS * Math.pow(2, attempt), + MAX_BACKOFF_MS, + ); + await sleep(delayMs); + } + } + } + + return err( + SorokitErrorCode.NETWORK_ERROR, + `Payment webhook delivery failed after ${maxRetries + 1} attempts to ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + lastError, + ); +} + +/** Deliver a payload through any registered notification channels. */ +async function deliverToChannels( + payload: PaymentWebhookPayload, +): Promise[]> { + return Promise.all( + Array.from(channelRegistry).map(async (channel) => { + try { + const delivered = await channel.deliver(payload); + if (delivered) return ok(undefined); + return err( + SorokitErrorCode.NETWORK_ERROR, + `Notification channel '${channel.name}' rejected payload for event '${payload.event}'`, + ); + } catch (error) { + return err( + SorokitErrorCode.NETWORK_ERROR, + `Notification channel '${channel.name}' failed for event '${payload.event}': ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + }), + ); +} + +/** + * Trigger delivery of a payment notification to all matching subscribers and + * wait for the results. + * + * Deliveries run concurrently and are reported independently so one failing + * endpoint cannot mask another. + * + * @param event - The payment event that occurred. + * @param input - Payment identity (transactionId, account, optional eventId). + * @returns One result per delivery target (ok or error). + */ +export async function triggerPaymentNotifications( + event: PaymentNotificationEvent, + input: PaymentNotificationInput, +): Promise[]> { + if (!isPaymentNotificationEvent(event)) { + return [ + err( + SorokitErrorCode.INVALID_CONFIG, + `Invalid payment event type: ${String(event)}`, + ), + ]; + } + if (typeof input.transactionId !== "string" || input.transactionId.length === 0) { + return [err(SorokitErrorCode.INVALID_CONFIG, "transactionId must be a non-empty string")]; + } + if (typeof input.account !== "string" || input.account.length === 0) { + return [err(SorokitErrorCode.INVALID_CONFIG, "account must be a non-empty string")]; + } + + const payload: PaymentWebhookPayload = { + eventId: resolveEventId(input, event), + event, + timestamp: new Date().toISOString(), + transactionId: input.transactionId, + account: input.account, + }; + + const registrations = listPaymentWebhooks(event); + const webhookResults = await Promise.all( + registrations.map((registration) => sendPayloadWithRetry(registration, payload)), + ); + const channelResults = await deliverToChannels(payload); + return [...webhookResults, ...channelResults]; +} + +/** + * Fire-and-forget dispatch of a payment notification. + * + * Never throws, never rejects, and never blocks transaction processing while + * subscribers are being notified. Individual delivery failures are reported by + * {@link triggerPaymentNotifications} and intentionally swallowed here. + * + * @param event - The payment event that occurred. + * @param input - Payment identity (transactionId, account, optional eventId). + */ +export function dispatchPaymentNotification( + event: PaymentNotificationEvent, + input: PaymentNotificationInput, +): void { + try { + void triggerPaymentNotifications(event, input).catch(() => { + // Swallow: notification delivery must never surface into the + // transaction/payment processing flow. + }); + } catch { + // Defensive: even synchronous failures must not propagate. + } +}