UNPKG

alepha

Version:

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

931 lines (930 loc) 31 kB
import { $inject, $module, Alepha, AlephaError, z } from "alepha"; import { $secure } from "alepha/security"; import { $action, $route, okSchema } from "alepha/server"; import { $entity, $repository, db, pageQuerySchema } from "alepha/orm"; import { $job } from "alepha/api/jobs"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { CryptoProvider } from "alepha/crypto"; //#region ../../src/api/payments/entities/paymentIntents.ts const paymentIntents = $entity({ name: "payment_intents", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), organizationId: db.organization(), amount: z.integer(), currency: z.text({ size: "short" }), status: z.enum([ "created", "processing", "authorized", "captured", "partially_refunded", "voided", "failed", "cancelled", "refunded", "expired" ]), providerRef: z.text().optional(), providerRaw: z.json().optional(), metadata: z.json().optional(), paymentMethodId: z.uuid().optional(), userId: z.uuid().optional() }), indexes: [ "status", "organizationId", "userId", "createdAt" ] }); //#endregion //#region ../../src/api/payments/schemas/intentSchemas.ts const createIntentSchema = z.object({ amount: z.integer().min(1), currency: z.text({ size: "short" }), metadata: z.json().optional(), paymentMethodId: z.uuid().optional() }); const createCheckoutSchema = z.object({ intentId: z.uuid(), returnUrl: z.text(), authorize: z.boolean().optional() }); const checkoutResponseSchema = z.object({ url: z.text(), intentId: z.text() }); const captureIntentSchema = z.object({ amount: z.integer().min(1).optional() }); const refundIntentSchema = z.object({ amount: z.integer().min(1), reason: z.text().optional() }); const recordCashSchema = z.object({ amount: z.integer().min(1), currency: z.text({ size: "short" }), metadata: z.json().optional() }); const intentQuerySchema = pageQuerySchema.extend({ status: z.text({ description: "Filter by status" }).optional(), userId: z.uuid().describe("Filter by user ID").optional() }); const intentResourceSchema = paymentIntents.schema; //#endregion //#region ../../src/api/payments/entities/refunds.ts const refunds = $entity({ name: "refunds", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), organizationId: db.organization(), intentId: z.uuid(), amount: z.integer(), currency: z.text({ size: "short" }), status: z.enum([ "pending", "processing", "completed", "failed" ]), reason: z.text().optional(), providerRef: z.text().optional() }), indexes: [ "intentId", "organizationId", "status" ] }); //#endregion //#region ../../src/api/payments/schemas/refundSchemas.ts const refundResourceSchema = refunds.schema; //#endregion //#region ../../src/api/payments/errors/PaymentError.ts var PaymentError = class extends AlephaError { status = 400; }; //#endregion //#region ../../src/api/payments/providers/PaymentProvider.ts var PaymentProvider = class {}; //#endregion //#region ../../src/api/payments/services/PaymentService.ts var PaymentService = class PaymentService { alepha = $inject(Alepha); log = $logger(); dateTime = $inject(DateTimeProvider); provider = $inject(PaymentProvider); intentRepo = $repository(paymentIntents); 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. */ 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. */ async createIntent(amount, currency, metadata, options) { return await this.intentRepo.create({ amount, currency: currency.toLowerCase(), status: "created", metadata, paymentMethodId: options?.paymentMethodId, userId: options?.userId }); } /** * Create a checkout session with the payment provider and * transition the intent to "processing". */ async createSession(intentId, returnUrl, authorize, userId, options) { const intent = await this.getIntent(intentId); this.assertStatus(intent, "created", "createSession"); if (userId && intent.userId && intent.userId !== userId) throw new PaymentError("Payment intent does not belong to this user"); 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. */ async handleWebhook(request) { 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. */ async handleParsedWebhook(event) { let intents = await this.intentRepo.findMany({ where: { providerRef: { eq: event.providerRef } }, limit: 1 }); 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]; 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. */ static VALID_WEBHOOK_TRANSITIONS = { processing: [ "authorized", "captured", "failed" ], authorized: ["captured", "failed"] }; async handleWebhookEvent(intentId, status, raw) { const intent = await this.getIntent(intentId); const eventMap = { authorized: "payments:authorized", captured: "payments:captured", failed: "payments:failed" }; if (!(status in eventMap)) { this.log.warn(`Unknown webhook status: ${status}`); return; } const webhookStatus = status; if (!PaymentService.VALID_WEBHOOK_TRANSITIONS[intent.status]?.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 }); 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. */ async capture(intentId, finalAmount) { 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. */ async void(intentId) { 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). */ async refund(intentId, amount, reason, options = {}) { const intent = await this.getIntent(intentId); if (intent.status !== "captured" && intent.status !== "partially_refunded") throw new PaymentError(`Cannot refund: intent ${intent.id} is '${intent.status}', expected 'captured' or 'partially_refunded'`); const totalRefunded = (await this.refundRepo.findMany({ where: { intentId: { eq: intent.id } } })).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; if (intent.providerRef) refundProviderRef = (await this.provider.refundPayment(intent.providerRef, amount, options)).providerRef; const refund = await this.refundRepo.create({ intentId: intent.id, organizationId: intent.organizationId, amount, currency: intent.currency, status: "completed", reason, providerRef: refundProviderRef }); const newStatus = totalRefunded + amount >= 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. */ async recordCashPayment(amount, currency, metadata) { const intent = await this.intentRepo.create({ amount, currency: currency.toLowerCase(), status: "captured", metadata }); 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. */ async cancel(intentId) { 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. */ async getIntent(intentId) { return await this.intentRepo.getById(intentId); } /** * Find payment intents with optional filters and pagination. */ async findIntents(query) { const where = this.intentRepo.createQueryWhere(); if (query.status) where.status = { eq: query.status }; if (query.userId) where.userId = { eq: query.userId }; return await this.intentRepo.paginate(query, { where }, { count: true }); } assertStatus(intent, expected, operation) { if (intent.status !== expected) throw new PaymentError(`Cannot ${operation}: intent ${intent.id} is '${intent.status}', expected '${expected}'`); } }; //#endregion //#region ../../src/api/payments/controllers/AdminPaymentController.ts var AdminPaymentController = class { url = "/admin/payments"; group = "admin:payments"; payments = $inject(PaymentService); /** * List payment intents with pagination and filtering. */ listIntents = $action({ path: `${this.url}/intents`, group: this.group, use: [$secure({ permissions: ["payments:read"] })], description: "List payment intents", schema: { query: intentQuerySchema, response: z.page(intentResourceSchema) }, handler: ({ query }) => this.payments.findIntents(query) }); /** * Get a payment intent by ID. */ getIntent = $action({ path: `${this.url}/intents/:id`, group: this.group, use: [$secure({ permissions: ["payments:read"] })], description: "Get payment intent details", schema: { params: z.object({ id: z.uuid() }), response: intentResourceSchema }, handler: ({ params }) => this.payments.getIntent(params.id) }); /** * Capture an authorized intent. */ captureIntent = $action({ method: "POST", path: `${this.url}/intents/:id/capture`, group: this.group, use: [$secure({ permissions: ["payments:write"] })], description: "Capture an authorized payment intent", schema: { params: z.object({ id: z.uuid() }), body: captureIntentSchema, response: intentResourceSchema }, handler: ({ params, body }) => this.payments.capture(params.id, body.amount) }); /** * Void an authorized intent. */ voidIntent = $action({ method: "POST", path: `${this.url}/intents/:id/void`, group: this.group, use: [$secure({ permissions: ["payments:write"] })], description: "Void an authorized payment intent", schema: { params: z.object({ id: z.uuid() }), response: intentResourceSchema }, handler: ({ params }) => this.payments.void(params.id) }); /** * Refund a captured intent. */ refundIntent = $action({ method: "POST", path: `${this.url}/intents/:id/refund`, group: this.group, use: [$secure({ permissions: ["payments:write"] })], description: "Issue partial or full refund", schema: { params: z.object({ id: z.uuid() }), body: refundIntentSchema, response: refundResourceSchema }, handler: ({ params, body }) => this.payments.refund(params.id, body.amount, body.reason) }); /** * Cancel a created intent. */ cancelIntent = $action({ method: "POST", path: `${this.url}/intents/:id/cancel`, group: this.group, use: [$secure({ permissions: ["payments:write"] })], description: "Cancel a created payment intent", schema: { params: z.object({ id: z.uuid() }), response: intentResourceSchema }, handler: ({ params }) => this.payments.cancel(params.id) }); /** * Record a cash payment. */ recordCash = $action({ method: "POST", path: `${this.url}/cash`, group: this.group, use: [$secure({ permissions: ["payments:write"] })], description: "Record a cash payment", schema: { body: recordCashSchema, response: intentResourceSchema }, handler: ({ body }) => this.payments.recordCashPayment(body.amount, body.currency, body.metadata) }); /** * PSP webhook endpoint (not under /admin, no auth — verified by provider). */ webhook = $action({ method: "POST", path: "/payments/webhook", group: this.group, description: "PSP webhook endpoint", schema: { response: okSchema }, handler: async (request) => { await this.payments.handleWebhook(request.raw.web.req); return { ok: true }; } }); }; //#endregion //#region ../../src/api/payments/providers/MemoryPaymentProvider.ts var MemoryPaymentProvider = class { crypto = $inject(CryptoProvider); charges = /* @__PURE__ */ new Map(); refundRecords = /* @__PURE__ */ new Map(); methods = /* @__PURE__ */ new Map(); expiredSessions = /* @__PURE__ */ new Set(); async createSession(intent, options) { const providerRef = `mem_session_${this.crypto.randomUUID()}`; const status = options.authorize ? "authorized" : "captured"; this.charges.set(providerRef, { providerRef, amount: intent.amount, status }); return { url: `/payments/mock-checkout/${intent.id}?returnUrl=${encodeURIComponent(options.returnUrl)}`, providerRef }; } async capturePayment(providerRef, amount) { const charge = this.charges.get(providerRef); if (charge) { charge.status = "captured"; charge.amount = amount; } } async voidPayment(providerRef) { const charge = this.charges.get(providerRef); if (charge) charge.status = "voided"; } async refundPayment(providerRef, amount) { const refundRef = `mem_refund_${this.crypto.randomUUID()}`; this.refundRecords.set(refundRef, { providerRef: refundRef, chargeRef: providerRef, amount }); return { providerRef: refundRef }; } async parseWebhook(request) { const body = await request.json(); return { providerRef: body.providerRef, status: body.status, raw: body }; } async createPaymentMethod(_userId, _token) { const providerRef = `mem_pm_${this.crypto.randomUUID()}`; const result = { providerRef, type: "card", brand: "visa", last4: "4242", expMonth: 12, expYear: 2030 }; this.methods.set(providerRef, result); return result; } async deletePaymentMethod(providerRef) { this.methods.delete(providerRef); } async expireSession(providerRef) { this.expiredSessions.add(providerRef); } wasCharged(providerRef) { return this.charges.get(providerRef)?.status === "captured"; } wasRefunded(providerRef) { return Array.from(this.refundRecords.values()).some((r) => r.chargeRef === providerRef); } wasExpired(providerRef) { return this.expiredSessions.has(providerRef); } getCharges() { return Array.from(this.charges.values()); } getRefunds() { return Array.from(this.refundRecords.values()); } }; //#endregion //#region ../../src/api/payments/controllers/MockCheckoutController.ts const FORBIDDEN_HTML = "<!doctype html><meta charset=utf-8><title>Mock checkout</title><p>Mock checkout is only available with the in-memory payment provider.</p>"; const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "\"" ? "&quot;" : "&#39;"); const formatAmount = (cents, currency) => { return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`; }; const renderPage = (opts) => `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Checkout</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> :root { color-scheme: light; } * { box-sizing: border-box; } body { display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; padding:1rem; background:#f8fafc; color:#0b1120; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } .card { background:#fff; border:1px solid #e2e8f0; border-radius:16px; box-shadow:0 12px 40px rgba(11,17,32,.08); max-width:400px; width:100%; overflow:hidden; } .head { padding:1.5rem 1.75rem 1.25rem; border-bottom:1px solid #e2e8f0; } .badge { display:inline-flex; align-items:center; gap:.35rem; background:#fff7ed; color:#c2410c; border:1px solid #fed7aa; font-size:.7rem; font-weight:600; letter-spacing:.04em; padding:3px 10px; border-radius:999px; text-transform:uppercase; } .label { margin:.9rem 0 0; font-size:.8rem; color:#64748b; } .amount { font-size:2.25rem; font-weight:800; letter-spacing:-.02em; margin:.15rem 0 0; } .body { padding:1.5rem 1.75rem 1.75rem; } .field { margin-bottom: .9rem; } .field label { display:block; font-size:.75rem; font-weight:600; color:#334155; margin-bottom:.3rem; } .field input { width:100%; padding:.65rem .8rem; border:1px solid #e2e8f0; border-radius:10px; font-size:.9rem; background:#f8fafc; color:#475569; font-family:inherit; } .grid { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; } .pay { width:100%; padding:.85rem 1rem; border-radius:999px; border:0; cursor:pointer; background:#f97316; color:#fff; font-size:1rem; font-weight:700; font-family:inherit; box-shadow:0 6px 20px rgba(249,115,22,.35); transition:transform .15s, box-shadow .15s; } .pay:hover { transform:translateY(-1px); box-shadow:0 10px 26px rgba(249,115,22,.45); } .cancel { width:100%; margin-top:.6rem; padding:.7rem 1rem; border-radius:999px; cursor:pointer; background:transparent; border:0; color:#64748b; font-size:.85rem; font-family:inherit; } .cancel:hover { color:#0b1120; } .secure { display:flex; align-items:center; justify-content:center; gap:.35rem; font-size:.72rem; color:#94a3b8; margin-top:1rem; } .meta { font-size:.68rem; color:#cbd5e1; margin-top:.75rem; word-break:break-all; text-align:center; } </style> </head> <body> <div class="card"> <div class="head"> <span class="badge">Mode test — paiement simulé</span> <p class="label">Montant à payer</p> <p class="amount">${escapeHtml(opts.amount)}</p> </div> <div class="body"> <div class="field"> <label for="cc-num">Numéro de carte</label> <input id="cc-num" value="4242 4242 4242 4242" readonly /> </div> <div class="grid"> <div class="field"> <label for="cc-exp">Expiration</label> <input id="cc-exp" value="12 / 34" readonly /> </div> <div class="field"> <label for="cc-cvc">CVC</label> <input id="cc-cvc" value="123" readonly /> </div> </div> <form method="post" action="/payments/mock-checkout/${opts.intentId}/confirm"> <input type="hidden" name="returnUrl" value="${escapeHtml(opts.returnUrl)}" /> <button type="submit" class="pay">Payer ${escapeHtml(opts.amount)}</button> </form> <form method="post" action="/payments/mock-checkout/${opts.intentId}/cancel"> <input type="hidden" name="returnUrl" value="${escapeHtml(opts.returnUrl)}" /> <button type="submit" class="cancel">Annuler et revenir au site</button> </form> <p class="secure">&#128274; Aucune carte n'est débitée — environnement de développement</p> <p class="meta">intent: ${opts.intentId}</p> </div> </div> </body> </html>`; const appendStatusParam = (returnUrl, status) => { try { const url = new URL(returnUrl, "http://placeholder.local"); url.searchParams.set("booking", status); return returnUrl.startsWith("http") ? url.toString() : url.pathname + url.search + url.hash; } catch { return returnUrl; } }; var MockCheckoutController = class { url = "/payments/mock-checkout"; payments = $inject(PaymentService); provider = $inject(PaymentProvider); isMemoryProvider() { return this.provider instanceof MemoryPaymentProvider; } forbidden(reply) { reply.headers["content-type"] = "text/html; charset=utf-8"; reply.status = 403; return FORBIDDEN_HTML; } mockCheckoutPage = $route({ method: "GET", path: `${this.url}/:id`, schema: { params: z.object({ id: z.uuid() }), query: z.object({ returnUrl: z.text({ size: "rich" }).optional() }) }, handler: async ({ params, query, reply }) => { if (!this.isMemoryProvider()) return this.forbidden(reply); const intent = await this.payments.getIntent(params.id); reply.headers["content-type"] = "text/html; charset=utf-8"; return renderPage({ intentId: intent.id, amount: formatAmount(intent.amount, intent.currency), returnUrl: query.returnUrl ?? "/" }); } }); mockCheckoutConfirm = $route({ method: "POST", path: `${this.url}/:id/confirm`, schema: { params: z.object({ id: z.uuid() }), body: z.object({ returnUrl: z.text({ size: "rich" }).optional() }) }, handler: async ({ params, body, reply }) => { if (!this.isMemoryProvider()) return this.forbidden(reply); await this.payments.handleWebhookEvent(params.id, "captured"); reply.redirect(appendStatusParam(body.returnUrl ?? "/", "success"), 302); } }); mockCheckoutCancel = $route({ method: "POST", path: `${this.url}/:id/cancel`, schema: { params: z.object({ id: z.uuid() }), body: z.object({ returnUrl: z.text({ size: "rich" }).optional() }) }, handler: async ({ params, body, reply }) => { if (!this.isMemoryProvider()) return this.forbidden(reply); await this.payments.handleWebhookEvent(params.id, "failed"); reply.redirect(appendStatusParam(body.returnUrl ?? "/", "cancel"), 302); } }); }; //#endregion //#region ../../src/api/payments/entities/paymentMethods.ts const paymentMethods = $entity({ name: "payment_methods", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), organizationId: db.organization(), userId: z.uuid(), type: z.text({ size: "short" }), brand: z.text({ size: "short" }).optional(), last4: z.text({ size: "short" }).optional(), expMonth: z.integer().optional(), expYear: z.integer().optional(), isDefault: z.boolean(), providerRef: z.text() }), indexes: ["userId", "organizationId"] }); //#endregion //#region ../../src/api/payments/schemas/paymentMethodSchemas.ts const addPaymentMethodSchema = z.object({ token: z.text() }); const paymentMethodResourceSchema = paymentMethods.schema; //#endregion //#region ../../src/api/payments/services/PaymentMethodService.ts var PaymentMethodService = class { log = $logger(); provider = $inject(PaymentProvider); methodRepo = $repository(paymentMethods); async addPaymentMethod(userId, organizationId, token) { const result = await this.provider.createPaymentMethod(userId, token); const existing = await this.methodRepo.findMany({ where: { userId: { eq: userId } } }); return await this.methodRepo.create({ userId, organizationId, type: result.type, brand: result.brand, last4: result.last4, expMonth: result.expMonth, expYear: result.expYear, isDefault: existing.length === 0, providerRef: result.providerRef }); } async listPaymentMethods(userId) { return await this.methodRepo.findMany({ where: { userId: { eq: userId } } }); } async removePaymentMethod(methodId, userId) { const method = await this.methodRepo.getById(methodId); if (method.userId !== userId) throw new PaymentError("Cannot remove another user's payment method"); await this.provider.deletePaymentMethod(method.providerRef); await this.methodRepo.deleteById(method.id); } async setDefault(methodId, userId) { const method = await this.methodRepo.getById(methodId); if (method.userId !== userId) throw new PaymentError("Cannot modify another user's payment method"); const userMethods = await this.methodRepo.findMany({ where: { userId: { eq: userId } } }); for (const m of userMethods) if (m.isDefault) await this.methodRepo.updateById(m.id, { isDefault: false }); return await this.methodRepo.updateById(method.id, { isDefault: true }); } }; //#endregion //#region ../../src/api/payments/controllers/PaymentController.ts var PaymentController = class { url = "/payments"; group = "payments"; payments = $inject(PaymentService); paymentMethods = $inject(PaymentMethodService); /** * List the current user's saved payment methods. */ listPaymentMethods = $action({ path: `${this.url}/payment-methods`, group: this.group, use: [$secure()], description: "List current user's saved payment methods", schema: { response: z.array(paymentMethodResourceSchema) }, handler: ({ user }) => this.paymentMethods.listPaymentMethods(user.id) }); /** * Add a new payment method. */ addPaymentMethod = $action({ method: "POST", path: `${this.url}/payment-methods`, group: this.group, use: [$secure()], description: "Tokenize and store a new payment method", schema: { body: addPaymentMethodSchema, response: paymentMethodResourceSchema }, handler: ({ body, user }) => { if (!user.organization) throw new PaymentError("Organization is required to add a payment method"); return this.paymentMethods.addPaymentMethod(user.id, user.organization, body.token); } }); /** * Remove a payment method. */ removePaymentMethod = $action({ method: "DELETE", path: `${this.url}/payment-methods/:id`, group: this.group, use: [$secure()], description: "Remove own payment method", schema: { params: z.object({ id: z.uuid() }), response: okSchema }, handler: async ({ params, user }) => { await this.paymentMethods.removePaymentMethod(params.id, user.id); return { ok: true, id: params.id }; } }); /** * Set a payment method as default. */ setDefaultPaymentMethod = $action({ method: "PATCH", path: `${this.url}/payment-methods/:id/default`, group: this.group, use: [$secure()], description: "Set as default payment method", schema: { params: z.object({ id: z.uuid() }), response: paymentMethodResourceSchema }, handler: ({ params, user }) => this.paymentMethods.setDefault(params.id, user.id) }); /** * Create a checkout session. */ createCheckout = $action({ method: "POST", path: `${this.url}/checkout`, group: this.group, use: [$secure()], description: "Create checkout session and return URL", schema: { body: createCheckoutSchema, response: checkoutResponseSchema }, handler: ({ body, user }) => this.payments.createSession(body.intentId, body.returnUrl, body.authorize, user.id) }); }; //#endregion //#region ../../src/api/payments/index.ts const AlephaApiPayments = $module({ name: "alepha.api.payments", services: [ AdminPaymentController, PaymentController, MockCheckoutController, PaymentProvider, PaymentService, PaymentMethodService ], variants: [MemoryPaymentProvider], register: (alepha) => { alepha.with({ optional: true, provide: PaymentProvider, use: MemoryPaymentProvider }); } }); //#endregion export { AdminPaymentController, AlephaApiPayments, MemoryPaymentProvider, MockCheckoutController, PaymentController, PaymentError, PaymentMethodService, PaymentProvider, PaymentService, addPaymentMethodSchema, captureIntentSchema, checkoutResponseSchema, createCheckoutSchema, createIntentSchema, intentQuerySchema, intentResourceSchema, paymentIntents, paymentMethodResourceSchema, paymentMethods, recordCashSchema, refundIntentSchema, refundResourceSchema, refunds }; //# sourceMappingURL=index.js.map