import "server-only"; import type { PaymentCertificationCommand, PaymentCheckoutCommand, IgnoredPaymentWebhook, PaymentProvider, PaymentProviderError, PaymentProviderPayment, PaymentWebhookInput, } from "@salixcommerce/core"; import type { CommerceRepository } from "../commerce/repository"; import { hashCommerceRequest } from "../commerce/capability"; import type { FakePaymentProvider } from "./fake-payment-provider"; import { paymentFailure, type PaymentEventResult, type PaymentRepository, type PaymentResult, } from "./repository"; interface PaymentServiceDependencies { readonly commerceRepository: CommerceRepository; readonly fakeProvider?: FakePaymentProvider; readonly provider: PaymentProvider | null; readonly repository: PaymentRepository; } export interface PaymentService { beginCheckout( input: Readonly<{ capabilityHash: string; command: PaymentCheckoutCommand; orderReference: string; }>, ): Promise< PaymentResult<{ checkoutUrl: string; payment: PaymentEventResult["payment"]; replayed: boolean; }> >; processWebhook( input: PaymentWebhookInput, ): Promise>; simulateCertificationEvent( input: Readonly<{ capabilityHash: string; command: PaymentCertificationCommand; providerPaymentReference: string; }>, ): Promise>; } export function createPaymentService(dependencies: PaymentServiceDependencies): PaymentService { const service: PaymentService = { async beginCheckout(input) { if (dependencies.provider === null) return unavailable(); const order = await dependencies.commerceRepository.loadOrder( input.capabilityHash, input.orderReference, ); if (order === null) { return failure(paymentFailure("ORDER_NOT_FOUND", "Order not found.", 404)); } if (order.status !== "pending_payment") { return failure( paymentFailure( "ILLEGAL_PAYMENT_TRANSITION", "Only a pending order can begin payment.", 409, ), ); } const checkoutResult = await dependencies.provider.createCheckout({ amount: order.merchandiseSubtotal, idempotencyKey: input.command.idempotencyKey, orderReference: order.reference, }); if (!checkoutResult.ok) return failure(mapProviderError(checkoutResult.error)); const recorded = await dependencies.repository.recordCheckout({ capabilityHash: input.capabilityHash, checkout: checkoutResult.data, idempotencyKey: input.command.idempotencyKey, requestHash: hashCommerceRequest({ checkout: checkoutResult.data, idempotencyKey: input.command.idempotencyKey, }), }); if (!recorded.ok) return recorded; return { data: { checkoutUrl: recorded.data.payment.checkoutUrl, payment: recorded.data.payment, replayed: recorded.data.replayed, }, ok: true, }; }, async processWebhook(input) { if (dependencies.provider === null) return unavailable(); const verified = await dependencies.provider.verifyWebhook(input); if (!verified.ok) return failure(mapProviderError(verified.error)); if (verified.data.disposition === "ignored") { return { data: verified.data, ok: true }; } return dependencies.repository.applyEvent({ event: verified.data.event, eventHash: hashCommerceRequest(verified.data.event), }); }, async simulateCertificationEvent(input) { const provider = dependencies.fakeProvider; if (provider === undefined || dependencies.provider?.key !== "fake") return unavailable(); const payment = await dependencies.repository.loadAuthorizedPayment( input.capabilityHash, "fake", input.providerPaymentReference, ); if ( payment === null || payment.provider !== "fake" || payment.providerPaymentReference !== input.providerPaymentReference ) { return failure(paymentFailure("PAYMENT_NOT_FOUND", "Payment not found.", 404)); } provider.hydratePayment(toProviderPayment(payment)); const kindByOutcome = { cancel: "checkout.cancelled", fail: "checkout.failed", succeed: "checkout.succeeded", } as const; const simulated = await provider.simulateEvent({ idempotencyKey: input.command.idempotencyKey, kind: kindByOutcome[input.command.outcome], providerPaymentReference: payment.providerPaymentReference, }); if (!simulated.ok) return failure(mapProviderError(simulated.error)); const processed = await service.processWebhook(simulated.data); if (!processed.ok) return processed; if ("disposition" in processed.data) { return failure(paymentFailure("INVALID_REQUEST", "Fake event was ignored.", 400)); } return { data: processed.data, ok: true }; }, }; return Object.freeze(service); } function toProviderPayment(payment: PaymentEventResult["payment"]): PaymentProviderPayment { return { amount: payment.amount, orderReference: payment.orderReference, provider: payment.provider, providerPaymentReference: payment.providerPaymentReference, refundedAmount: payment.refundedAmount, status: payment.status, }; } function mapProviderError(error: PaymentProviderError) { if (error.code === "NOT_FOUND") { return paymentFailure("PAYMENT_NOT_FOUND", "Payment not found.", 404); } if (error.code === "INVALID_REQUEST" || error.code === "SIGNATURE_INVALID") { return paymentFailure("INVALID_REQUEST", "The verified payment request is invalid.", 400); } if (error.code === "REJECTED") { return paymentFailure("ILLEGAL_PAYMENT_TRANSITION", error.message, 409); } return paymentFailure( error.code === "CONFIGURATION" ? "PAYMENTS_UNAVAILABLE" : "PROVIDER_FAILURE", error.code === "CONFIGURATION" ? "Payments are not configured." : "The payment provider could not complete the request.", error.code === "CONFIGURATION" || error.retryable ? 503 : 500, ); } function unavailable(): PaymentResult { return failure( paymentFailure( "PAYMENTS_UNAVAILABLE", "Payments are unavailable until the local provider is configured.", 503, ), ); } function failure(error: ReturnType): PaymentResult { return { error, ok: false }; }