alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
198 lines (186 loc) • 7.5 kB
text/typescript
import { $inject, z } from "alepha";
import { $route, type ServerReply } from "alepha/server";
import { MemoryPaymentProvider } from "../providers/MemoryPaymentProvider.ts";
import { PaymentProvider } from "../providers/PaymentProvider.ts";
import { PaymentService } from "../services/PaymentService.ts";
const FORBIDDEN_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: string) =>
s.replace(/[&<>"']/g, (c) =>
c === "&"
? "&"
: c === "<"
? "<"
: c === ">"
? ">"
: c === '"'
? """
: "'",
);
const formatAmount = (cents: number, currency: string) => {
const value = (cents / 100).toFixed(2);
return `${value} ${currency.toUpperCase()}`;
};
const renderPage = (opts: {
intentId: string;
amount: string;
returnUrl: string;
}) => `
<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: string, status: "success" | "cancel") => {
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;
}
};
export class MockCheckoutController {
protected readonly url = "/payments/mock-checkout";
protected readonly payments = $inject(PaymentService);
protected readonly provider = $inject(PaymentProvider);
protected isMemoryProvider() {
return this.provider instanceof MemoryPaymentProvider;
}
protected forbidden(reply: ServerReply) {
reply.headers["content-type"] = "text/html; charset=utf-8";
reply.status = 403;
return FORBIDDEN_HTML;
}
public readonly 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 ?? "/",
});
},
});
public readonly 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);
},
});
public readonly 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);
},
});
}