My App
Intégrations

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

packages/api/src/modules/payment/payment.routes.ts
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

apps/pwa/src/features/check-in/check-in-payment.tsx
"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).

apps/server/src/routes/webhooks.ts
.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)

packages/api/src/jobs/stripe-webhook.job.ts
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 :

packages/api/src/modules/booking/booking.service.ts
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 exp
  • 4000 0025 0000 3155 — requires 3DS
  • 4000 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 server
  • STRIPE_PUBLISHABLE_KEY : OK en NEXT_PUBLIC_*
  • STRIPE_WEBHOOK_SECRET : env server, vérifié à chaque webhook
  • Les metadata Stripe ne contiennent pas de PII (pas d'email, pas de téléphone). Juste des IDs.

Lien

On this page