alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
931 lines (930 loc) • 31 kB
JavaScript
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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === "\"" ? """ : "'");
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">🔒 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