UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

464 lines (407 loc) 13.8 kB
import { $inject, Alepha } from "alepha"; import { $job } from "alepha/api/jobs"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $repository } from "alepha/orm"; import { type PaymentIntentEntity, paymentIntents, } from "../entities/paymentIntents.ts"; import { type RefundEntity, refunds } from "../entities/refunds.ts"; import { PaymentError } from "../errors/PaymentError.ts"; import { PaymentProvider, type WebhookEvent, } from "../providers/PaymentProvider.ts"; export class PaymentService { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly dateTime = $inject(DateTimeProvider); protected readonly provider = $inject(PaymentProvider); protected readonly intentRepo = $repository(paymentIntents); protected readonly refundRepo = $repository(refunds); /** * Expires stale payment intents that have been in "processing" status * for more than 30 minutes. Runs every 5 minutes — shares the CF wrangler * trigger with the jobs sweep so no extra binding is consumed. */ protected readonly expireStaleIntents = $job({ name: "api:payments:expireStaleIntents", cron: "*/5 * * * *", handler: async () => { const cutoff = this.dateTime.now().subtract(30, "minutes").toISOString(); const stale = await this.intentRepo.findMany({ where: { status: { eq: "processing" }, createdAt: { lt: cutoff } }, }); for (const intent of stale) { if (intent.providerRef) { try { await this.provider.expireSession(intent.providerRef); } catch (error) { this.log.warn( `Failed to expire session for intent ${intent.id}`, error, ); } } await this.intentRepo.updateById(intent.id, { status: "expired" }); this.log.info(`Expired stale intent ${intent.id}`); } }, }); /** * Create a new payment intent in "created" status. */ public async createIntent( amount: number, currency: string, metadata?: unknown, options?: { paymentMethodId?: string; userId?: string }, ): Promise<PaymentIntentEntity> { return await this.intentRepo.create({ amount, currency: currency.toLowerCase(), status: "created", metadata: metadata as any, paymentMethodId: options?.paymentMethodId, userId: options?.userId, }); } /** * Create a checkout session with the payment provider and * transition the intent to "processing". */ public async createSession( intentId: string, returnUrl: string, authorize?: boolean, userId?: string, options?: { stripeAccount?: string; applicationFeeAmount?: number; /** * Pre-fills the payer's email on the PSP checkout page. Useful when * the session runs on a sub-account (Stripe connected account) where * no customer object exists — without it the hosted checkout makes * the payer retype an address the platform already knows. */ customerEmail?: string; }, ): Promise<{ url: string; intentId: string }> { const intent = await this.getIntent(intentId); this.assertStatus(intent, "created", "createSession"); // Verify intent ownership if userId is provided if (userId && intent.userId && intent.userId !== userId) { throw new PaymentError("Payment intent does not belong to this user"); } // Associate intent with user if not already set if (userId && !intent.userId) { await this.intentRepo.updateById(intent.id, { userId }); } const result = await this.provider.createSession(intent, { returnUrl, authorize, stripeAccount: options?.stripeAccount, applicationFeeAmount: options?.applicationFeeAmount, customerEmail: options?.customerEmail, }); await this.intentRepo.updateById(intent.id, { status: "processing", providerRef: result.providerRef, }); return { url: result.url, intentId: intent.id }; } /** * Handle an incoming webhook from the payment provider. */ public async handleWebhook(request: Request): Promise<void> { const event = await this.provider.parseWebhook(request); await this.handleParsedWebhook(event); } /** * Resolve an already-parsed webhook event to its stored intent and apply * it. Split from `handleWebhook` so callers that parse with a DIFFERENT * verification path (e.g. a connected-accounts endpoint that must first * route the event to a tenant) can reuse the matching + transition logic. */ public async handleParsedWebhook(event: WebhookEvent): Promise<void> { let intents = await this.intentRepo.findMany({ where: { providerRef: { eq: event.providerRef } }, limit: 1, }); // Session events reference two PSP objects (session + PaymentIntent); // the stored ref is whichever existed at creation time — try the other. if (intents.length === 0 && event.providerRefAlt) { intents = await this.intentRepo.findMany({ where: { providerRef: { eq: event.providerRefAlt } }, limit: 1, }); } if (intents.length === 0) { this.log.warn(`Webhook for unknown providerRef: ${event.providerRef}`); return; } const intent = intents[0]; // Session events put the session id in `providerRef` and the (lazily // created) PaymentIntent in `providerRefAlt`. When the stored ref is // still the session id, upgrade it to the PI as soon as an event // reveals it — refunds can only target the PI, never the session. if ( event.providerRefAlt && intent.providerRef === event.providerRef && event.providerRefAlt !== event.providerRef ) { await this.intentRepo.updateById(intent.id, { providerRef: event.providerRefAlt, }); } await this.handleWebhookEvent(intent.id, event.status, event.raw); } /** * Process a webhook event by updating the intent status and emitting * the corresponding payment event. */ /** * Valid status transitions from webhook events. * Only these transitions are allowed — all others are silently ignored. */ protected static readonly VALID_WEBHOOK_TRANSITIONS: Record< string, string[] > = { processing: ["authorized", "captured", "failed"], authorized: ["captured", "failed"], }; public async handleWebhookEvent( intentId: string, status: string, raw?: unknown, ): Promise<void> { const intent = await this.getIntent(intentId); const eventMap = { authorized: "payments:authorized", captured: "payments:captured", failed: "payments:failed", } as const; type WebhookStatus = keyof typeof eventMap; if (!(status in eventMap)) { this.log.warn(`Unknown webhook status: ${status}`); return; } const webhookStatus = status as WebhookStatus; // Validate status transition const allowed = PaymentService.VALID_WEBHOOK_TRANSITIONS[intent.status]; if (!allowed?.includes(webhookStatus)) { this.log.warn( `Ignoring webhook: cannot transition ${intent.status} → ${webhookStatus}`, { intentId: intent.id }, ); return; } await this.intentRepo.updateById(intent.id, { status: webhookStatus, providerRaw: raw as any, }); await this.alepha.events.emit(eventMap[webhookStatus], { intentId: intent.id, amount: intent.amount, currency: intent.currency, metadata: intent.metadata, }); } /** * Capture a previously authorized payment. Optionally specify a different * amount for partial capture. */ public async capture( intentId: string, finalAmount?: number, ): Promise<PaymentIntentEntity> { const intent = await this.getIntent(intentId); this.assertStatus(intent, "authorized", "capture"); const amount = finalAmount ?? intent.amount; if (amount > intent.amount) { throw new PaymentError( `Capture amount ${amount} exceeds authorized amount ${intent.amount}`, ); } if (intent.providerRef) { await this.provider.capturePayment(intent.providerRef, amount); } const updated = await this.intentRepo.updateById(intent.id, { status: "captured", amount, }); await this.alepha.events.emit("payments:captured", { intentId: intent.id, amount, currency: intent.currency, metadata: intent.metadata, }); return updated; } /** * Void a previously authorized payment before capture. */ public async void(intentId: string): Promise<PaymentIntentEntity> { const intent = await this.getIntent(intentId); this.assertStatus(intent, "authorized", "void"); if (intent.providerRef) { await this.provider.voidPayment(intent.providerRef); } const updated = await this.intentRepo.updateById(intent.id, { status: "voided", }); await this.alepha.events.emit("payments:voided", { intentId: intent.id, amount: intent.amount, currency: intent.currency, metadata: intent.metadata, }); return updated; } /** * Refund a captured payment (partial or full). */ public async refund( intentId: string, amount: number, reason?: string, options: { /** * PSP sub-account holding the charge (Stripe connected account) — * required to refund payments that were created with the same option, * e.g. direct charges on a club's connected account. */ stripeAccount?: string; } = {}, ): Promise<RefundEntity> { const intent = await this.getIntent(intentId); // Allow refunds from both "captured" and "partially_refunded" states if ( intent.status !== "captured" && intent.status !== "partially_refunded" ) { throw new PaymentError( `Cannot refund: intent ${intent.id} is '${intent.status}', expected 'captured' or 'partially_refunded'`, ); } // Validate refund amount against remaining refundable amount const existingRefunds = await this.refundRepo.findMany({ where: { intentId: { eq: intent.id } }, }); const totalRefunded = existingRefunds.reduce((sum, r) => sum + r.amount, 0); const remaining = intent.amount - totalRefunded; if (amount > remaining) { throw new PaymentError( `Refund amount ${amount} exceeds remaining refundable amount ${remaining}`, ); } let refundProviderRef: string | undefined; if (intent.providerRef) { const result = await this.provider.refundPayment( intent.providerRef, amount, options, ); refundProviderRef = result.providerRef; } const refund = await this.refundRepo.create({ intentId: intent.id, organizationId: intent.organizationId, amount, currency: intent.currency, status: "completed", reason, providerRef: refundProviderRef, }); // Set status based on whether fully or partially refunded const newTotalRefunded = totalRefunded + amount; const newStatus = newTotalRefunded >= intent.amount ? "refunded" : "partially_refunded"; await this.intentRepo.updateById(intent.id, { status: newStatus, }); await this.alepha.events.emit("payments:refunded", { intentId: intent.id, refundId: refund.id, amount, currency: intent.currency, metadata: intent.metadata, }); return refund; } /** * Record a cash or offline payment directly as captured, * bypassing the checkout flow. */ public async recordCashPayment( amount: number, currency: string, metadata?: unknown, ): Promise<PaymentIntentEntity> { const intent = await this.intentRepo.create({ amount, currency: currency.toLowerCase(), status: "captured", metadata: metadata as any, }); await this.alepha.events.emit("payments:captured", { intentId: intent.id, amount, currency, metadata, }); return intent; } /** * Cancel a payment intent that has not yet entered processing. */ public async cancel(intentId: string): Promise<PaymentIntentEntity> { const intent = await this.getIntent(intentId); this.assertStatus(intent, "created", "cancel"); const cancelled = await this.intentRepo.updateById(intent.id, { status: "cancelled", }); await this.alepha.events.emit("payments:cancelled", { intentId: intent.id, amount: intent.amount, currency: intent.currency, metadata: intent.metadata, }); return cancelled; } /** * Get a payment intent by ID. Throws NotFoundError if not found. */ public async getIntent(intentId: string): Promise<PaymentIntentEntity> { return await this.intentRepo.getById(intentId); } /** * Find payment intents with optional filters and pagination. */ public async findIntents(query: { status?: string; userId?: string; sort?: string; size?: number; page?: number; }) { const where = this.intentRepo.createQueryWhere(); if (query.status) where.status = { eq: query.status as PaymentIntentEntity["status"] }; if (query.userId) where.userId = { eq: query.userId }; return await this.intentRepo.paginate(query, { where }, { count: true }); } protected assertStatus( intent: PaymentIntentEntity, expected: PaymentIntentEntity["status"], operation: string, ): void { if (intent.status !== expected) { throw new PaymentError( `Cannot ${operation}: intent ${intent.id} is '${intent.status}', expected '${expected}'`, ); } } }