Stripe (Payment Element)
Paiement inline via Stripe Payment Element, webhook HMAC, flow complet guest → PMS
Payment Element inline, pas Checkout redirect (décidé avec le user : on garde le guest dans le flow app, pas de hop externe).
Architecture
Pourquoi Payment Element inline
Alternative rejetée : Stripe Checkout redirect (hosted page Stripe, le guest sort de notre app, revient via success_url).
Pour Checkout
- Trivial à implémenter (pas de Stripe.js côté client)
- Apple Pay / Google Pay / Link gérés out-of-box
- Maintenu par Stripe (UI toujours à jour)
Contre Checkout
- Le guest sort de la PWA — ressentie comme un hop fragile, surtout sur mobile
- Impossible de garder notre branding (on peut customiser mais pas entièrement)
- En cas d'erreur, le guest est sur
stripe.com, pas sur bell-app — moins rassurant - Apple Wallet passes, confirmations, notifications : il faut re-router après le redirect
Pour Payment Element inline (retenu)
- Le guest reste sur la PWA — transition fluide
- Styling 100 % contrôlé (theme Stripe Elements)
- Apple Pay / Google Pay / Link aussi supportés via
<PaymentElement /> - 3DS géré dans le flow sans redirect complet (modal iframe Stripe)
- Notifications et confirmations sur notre propre domaine
Contre Payment Element
- Nécessite
@stripe/stripe-js+@stripe/react-stripe-js(~50 KB) - Setup plus complexe que Checkout (Elements provider, publishable key, etc.)
Décision tranchée : Payment Element inline. Coût d'implémentation absorbé une fois, expérience guest clairement supérieure.
Implémentation
Backend : créer PaymentIntent
import { Elysia, t } from "elysia";
import { stripe } from "../../services/stripe/client";
import { requireAuth } from "../../plugins/auth";
export const paymentModule = new Elysia({ prefix: "/payment" })
.use(requireAuth)
.post("/create-intent", async ({ body, auth }) => {
const intent = await stripe.paymentIntents.create({
amount: Math.round(body.amount * 100), // cents
currency: body.currency,
automatic_payment_methods: { enabled: true },
metadata: {
userId: auth.userId,
organizationId: auth.organizationId,
orderType: body.orderType, // "room_service" | "spa" | ...
orderId: body.orderId,
},
});
return { clientSecret: intent.client_secret, intentId: intent.id };
}, {
body: t.Object({
amount: t.Number({ minimum: 0.5 }),
currency: t.String({ default: "EUR" }),
orderType: t.String(),
orderId: t.String(),
}),
});Frontend PWA : Payment Element
"use client";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { useState } from "react";
import { eden } from "~/lib/eden";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export function CheckInPayment({ amount, orderType, orderId }: Props) {
const [clientSecret, setClientSecret] = useState<string>();
useEffect(() => {
eden.payment["create-intent"].post({
amount,
currency: "EUR",
orderType,
orderId,
}).then(({ data }) => setClientSecret(data!.clientSecret));
}, [amount, orderType, orderId]);
if (!clientSecret) return <Spinner />;
return (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: bellAppearance, // branding Bell (colors, fonts)
}}
>
<PaymentForm />
</Elements>
);
}
function PaymentForm() {
const stripe = useStripe();
const elements = useElements();
const [processing, setProcessing] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/auth/waiting`,
},
});
if (error) {
// 3DS échoué ou autre
toast.error(error.message);
setProcessing(false);
}
// Si succès, Stripe redirect vers return_url
// MAIS le webhook est la source de vérité, pas ce redirect
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={processing}>
{processing ? "Traitement…" : "Payer"}
</button>
</form>
);
}Thème Stripe (branding Bell)
const bellAppearance = {
theme: "flat" as const,
variables: {
colorPrimary: "#f2930d",
colorBackground: "#f7f5f2",
colorText: "#422424",
colorDanger: "#dc2626",
fontFamily: "Acumin Pro, system-ui, sans-serif",
borderRadius: "16px",
},
rules: {
".Input": { borderRadius: "16px", padding: "12px" },
".Label": { fontWeight: "500" },
},
};Webhook Stripe
La source de vérité des paiements (pas le redirect front).
.post("/webhooks/stripe", async ({ headers, body, set }) => {
const signature = headers["stripe-signature"];
if (!signature) {
set.status = 400;
return { error: "Missing signature" };
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody, // body raw, pas parsé
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
set.status = 400;
return { error: "Invalid signature" };
}
// Enqueue le process pour async retry
await queues.stripeWebhook.add(event.type, { event });
// 200 OK immédiat pour que Stripe n'insiste pas
return { received: true };
});Events supportés (worker)
export async function processStripeWebhook(event: Stripe.Event) {
switch (event.type) {
case "payment_intent.succeeded":
await onPaymentSucceeded(event.data.object);
break;
case "payment_intent.payment_failed":
await onPaymentFailed(event.data.object);
break;
case "charge.refunded":
await onChargeRefunded(event.data.object);
break;
case "charge.dispute.created":
await onChargeDisputed(event.data.object); // alerte ops
break;
default:
logger.info({ eventType: event.type }, "Unhandled Stripe event");
}
}
async function onPaymentSucceeded(intent: Stripe.PaymentIntent) {
const { orderType, orderId } = intent.metadata;
const orderTable = pickTable(orderType); // "room_service_order" etc.
await db.update(orderTable).set({
paymentStatus: "captured",
capturedAt: new Date(),
}).where(eq(orderTable.id, orderId));
// Déclenche le bridge PMS pour poster la charge
const order = await loadOrderWithGuest(orderType, orderId);
if (order.guestId && order.organizationId) {
await bridgePostCharges(order.organizationId, order.guestId, order.items);
}
await pubsub.publish(`guest:${order.userId}:orders`, {
orderId,
status: "paid",
});
}Idempotency
Stripe envoie parfois plusieurs webhooks pour le même event (retry après timeout). Le job BullMQ utilise une clé d'idempotency {event.id} pour dédoublonner :
await queues.stripeWebhook.add(event.type, { event }, {
jobId: event.id, // BullMQ rejette un doublon
attempts: 5,
backoff: { type: "exponential", delay: 10_000 },
});Et côté DB, UPDATE ... SET payment_status = "captured" est idempotent par nature.
Refunds
Quand staff annule une commande :
export async function cancelOrder(opts: { orderId: string; reason?: string }) {
const order = await repo.getOrder(opts.orderId);
if (order.paymentStatus !== "captured") {
// pas capturé, on annule juste le PaymentIntent
await stripe.paymentIntents.cancel(order.paymentIntentId);
} else {
// déjà capturé, refund via Stripe
await stripe.refunds.create({
payment_intent: order.paymentIntentId,
metadata: { reason: opts.reason ?? "" },
});
}
await db.update(order).set({ status: "cancelled" });
// webhook charge.refunded mettra à jour payment_status quand Stripe confirme
}Frais de service
Le guest paie subtotal + 1 % service fee (hors frais Stripe classiques). Le service fee est visible dans le récap de paiement pour transparence.
const subtotal = items.reduce((s, i) => s + i.unitAmount * i.unitCount, 0);
const serviceFee = Math.round(subtotal * 0.01 * 100) / 100;
const total = subtotal + serviceFee;Côté compta HOAIY : le service fee est versé sur un compte Stripe Connect dédié HOAIY, le reste va au compte Stripe de l'hôtel (via Stripe Connect Express ou Direct, à trancher).
Tests
Test cards Stripe :
4242 4242 4242 4242— succès, any CVC, any future exp4000 0025 0000 3155— requires 3DS4000 0000 0000 9995— declined (insufficient funds)4000 0000 0000 0341— attached successfully, charged declined
Les tests d'intégration utilisent stripe-mock (container Docker dev) au lieu de la vraie API.
Sécurité
STRIPE_SECRET_KEY: jamais côté client, uniquement env var serverSTRIPE_PUBLISHABLE_KEY: OK enNEXT_PUBLIC_*STRIPE_WEBHOOK_SECRET: env server, vérifié à chaque webhook- Les
metadataStripe ne contiennent pas de PII (pas d'email, pas de téléphone). Juste des IDs.