import "server-only"; import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { normalizedPaymentEventSchema, paymentCheckoutInputSchema, paymentCheckoutResultSchema, paymentProviderPaymentSchema, paymentProviderReferenceSchema, paymentRefundInputSchema, paymentRefundResultSchema, paymentWebhookInputSchema, type NormalizedPaymentEvent, type PaymentCheckoutInput, type PaymentCheckoutResult, type PaymentEventKind, type PaymentProvider, type PaymentProviderError, type PaymentProviderPayment, type PaymentProviderResult, type PaymentRefundInput, type PaymentRefundResult, type PaymentStatus, type PaymentWebhookInput, } from "@salixcommerce/core"; interface FakePaymentProviderOptions { readonly clock?: () => Date; readonly secret: string; } interface FakePaymentRecord extends PaymentProviderPayment { readonly checkout: PaymentCheckoutResult; } interface FakeEventCommand { readonly amountMinor?: number; readonly idempotencyKey: string; readonly kind: PaymentEventKind; readonly providerPaymentReference: string; } export interface FakePaymentProvider extends PaymentProvider { hydratePayment(payment: PaymentProviderPayment): void; simulateEvent(input: FakeEventCommand): Promise>; } const invalidRequest = (message: string): PaymentProviderError => ({ code: "INVALID_REQUEST", message, retryable: false, }); export function createFakePaymentProvider( options: FakePaymentProviderOptions, ): FakePaymentProvider { if (options.secret.trim().length < 32) { throw new TypeError("Fake payment signing secret must contain at least 32 characters."); } const clock = options.clock ?? (() => new Date()); const checkoutsByIdempotency = new Map< string, { readonly hash: string; readonly result: PaymentCheckoutResult } >(); const payments = new Map(); const refundsByIdempotency = new Map< string, { readonly hash: string; readonly result: PaymentRefundResult } >(); const eventsByIdempotency = new Map< string, { readonly hash: string; readonly webhook: PaymentWebhookInput } >(); const provider = { key: "fake", async createCheckout(input: PaymentCheckoutInput) { const parsed = paymentCheckoutInputSchema.safeParse(input); if (!parsed.success) return failure(invalidRequest("Invalid fake checkout request.")); const hash = digest(parsed.data); const previous = checkoutsByIdempotency.get(parsed.data.idempotencyKey); if (previous !== undefined) { return previous.hash === hash ? success(previous.result) : failure(invalidRequest("Fake checkout idempotency key was reused.")); } const providerPaymentReference = providerReference("fake_pay", hash); const result = paymentCheckoutResultSchema.parse({ amount: parsed.data.amount, checkoutUrl: `/fake-checkout?payment=${encodeURIComponent(providerPaymentReference)}&order=${encodeURIComponent(parsed.data.orderReference)}`, orderReference: parsed.data.orderReference, provider: "fake", providerPaymentReference, status: "pending", }); checkoutsByIdempotency.set(parsed.data.idempotencyKey, { hash, result }); payments.set(providerPaymentReference, { amount: result.amount, checkout: result, orderReference: result.orderReference, provider: result.provider, providerPaymentReference, refundedAmount: { amountMinor: 0, currency: result.amount.currency }, status: "pending", }); return success(result); }, async createRefund(input: PaymentRefundInput) { const parsed = paymentRefundInputSchema.safeParse(input); if (!parsed.success) return failure(invalidRequest("Invalid fake refund request.")); const payment = payments.get(parsed.data.providerPaymentReference); if (payment === undefined) return failure(notFound()); if ( payment.orderReference !== parsed.data.orderReference || payment.amount.currency !== parsed.data.amount.currency ) { return failure(invalidRequest("Fake refund does not match the payment.")); } if (payment.status !== "succeeded" && payment.status !== "partially_refunded") { return failure(rejected("Only a successful fake payment can be refunded.")); } if ( parsed.data.amount.amountMinor === 0 || parsed.data.amount.amountMinor + payment.refundedAmount.amountMinor > payment.amount.amountMinor ) { return failure(rejected("Fake refund exceeds the captured amount.")); } const hash = digest(parsed.data); const previous = refundsByIdempotency.get(parsed.data.idempotencyKey); if (previous !== undefined) { return previous.hash === hash ? success(previous.result) : failure(invalidRequest("Fake refund idempotency key was reused.")); } const result = paymentRefundResultSchema.parse({ amount: parsed.data.amount, orderReference: parsed.data.orderReference, provider: "fake", providerPaymentReference: parsed.data.providerPaymentReference, providerRefundReference: providerReference("fake_ref", hash), status: "succeeded", }); refundsByIdempotency.set(parsed.data.idempotencyKey, { hash, result }); return success(result); }, hydratePayment(payment: PaymentProviderPayment) { const parsed = paymentProviderPaymentSchema.parse(payment); if (parsed.provider !== "fake") { throw new TypeError("Only fake payments can hydrate the fake provider."); } payments.set(parsed.providerPaymentReference, { ...parsed, checkout: paymentCheckoutResultSchema.parse({ amount: parsed.amount, checkoutUrl: `/fake-checkout?payment=${encodeURIComponent(parsed.providerPaymentReference)}&order=${encodeURIComponent(parsed.orderReference)}`, orderReference: parsed.orderReference, provider: parsed.provider, providerPaymentReference: parsed.providerPaymentReference, status: "pending", }), }); }, async retrievePayment(reference: string) { const parsed = paymentProviderReferenceSchema.safeParse(reference); if (!parsed.success) return failure(invalidRequest("Invalid fake payment reference.")); const payment = payments.get(parsed.data); if (payment === undefined) return failure(notFound()); return success( paymentProviderPaymentSchema.parse({ amount: payment.amount, orderReference: payment.orderReference, provider: payment.provider, providerPaymentReference: payment.providerPaymentReference, refundedAmount: payment.refundedAmount, status: payment.status, }), ); }, async simulateEvent(input: FakeEventCommand) { const payment = payments.get(input.providerPaymentReference); if (payment === undefined) return failure(notFound()); const amountMinor = input.amountMinor ?? payment.amount.amountMinor; const candidate = normalizedPaymentEventSchema.safeParse({ amount: { amountMinor, currency: payment.amount.currency }, eventId: providerReference( "fake_evt", digest({ amountMinor, idempotencyKey: input.idempotencyKey, kind: input.kind, providerPaymentReference: input.providerPaymentReference, }), ), kind: input.kind, occurredAt: clock().toISOString(), orderReference: payment.orderReference, provider: "fake", providerPaymentReference: payment.providerPaymentReference, }); if (!candidate.success) return failure(invalidRequest("Invalid fake payment event.")); const commandHash = digest({ ...input, amountMinor }); const previous = eventsByIdempotency.get(input.idempotencyKey); if (previous !== undefined) { return previous.hash === commandHash ? success(previous.webhook) : failure(invalidRequest("Fake event idempotency key was reused.")); } const nextStatus = eventStatus(payment, candidate.data); if (!nextStatus.ok) return nextStatus; const rawBody = JSON.stringify(candidate.data); const webhook = paymentWebhookInputSchema.parse({ rawBody, signature: sign(rawBody, options.secret), }); eventsByIdempotency.set(input.idempotencyKey, { hash: commandHash, webhook }); payments.set(payment.providerPaymentReference, nextStatus.data); return success(webhook); }, async verifyWebhook(input: PaymentWebhookInput) { const parsed = paymentWebhookInputSchema.safeParse(input); if (!parsed.success || !signatureMatches(parsed.data, options.secret)) { return failure({ code: "SIGNATURE_INVALID", message: "Fake webhook signature is invalid.", retryable: false, }); } let payload: unknown; try { payload = JSON.parse(parsed.data.rawBody); } catch { return failure(invalidRequest("Fake webhook payload is invalid JSON.")); } const normalized = normalizedPaymentEventSchema.safeParse(payload); return normalized.success ? success({ disposition: "event" as const, event: normalized.data }) : failure(invalidRequest("Fake webhook payload is invalid.")); }, } satisfies FakePaymentProvider; return Object.freeze(provider); } function eventStatus( payment: FakePaymentRecord, event: NormalizedPaymentEvent, ): PaymentProviderResult { if (event.kind === "refund.succeeded") { if (payment.status !== "succeeded" && payment.status !== "partially_refunded") { return failure(rejected("Only a successful fake payment can receive a refund event.")); } const refunded = payment.refundedAmount.amountMinor + event.amount.amountMinor; if (refunded > payment.amount.amountMinor) { return failure(rejected("Fake refund event exceeds the captured amount.")); } return success({ ...payment, refundedAmount: { amountMinor: refunded, currency: payment.amount.currency }, status: refunded === payment.amount.amountMinor ? "refunded" : "partially_refunded", }); } if (payment.status !== "pending") { return failure(rejected("Fake checkout event is no longer legal.")); } const statusByKind = { "checkout.cancelled": "cancelled", "checkout.failed": "failed", "checkout.succeeded": "succeeded", } as const satisfies Record, PaymentStatus>; return success({ ...payment, status: statusByKind[event.kind] }); } function success(data: T): PaymentProviderResult { return { data, ok: true }; } function failure(error: PaymentProviderError): PaymentProviderResult { return { error, ok: false }; } function notFound(): PaymentProviderError { return { code: "NOT_FOUND", message: "Fake payment not found.", retryable: false }; } function rejected(message: string): PaymentProviderError { return { code: "REJECTED", message, retryable: false }; } function providerReference(prefix: string, hash: string): string { return `${prefix}_${hash.slice(0, 32)}`; } function digest(value: unknown): string { return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); } function sign(rawBody: string, secret: string): string { return createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); } function signatureMatches(input: PaymentWebhookInput, secret: string): boolean { const expected = Buffer.from(sign(input.rawBody, secret), "hex"); const actual = /^[0-9a-f]{64}$/iu.test(input.signature) ? Buffer.from(input.signature, "hex") : Buffer.alloc(0); return expected.length === actual.length && timingSafeEqual(expected, actual); }