@mostajs/booking-payment
v0.1.0
Published
Adapter thin entre @mostajs/booking et @mostajs/payment (multi-provider Stripe/Chargily/Satim/PayPal). Hook beforeReserve capture deposit, webhook handler confirme reservation après paiement, refund automatique selon cancellation window.
Readme
@mostajs/booking-payment
Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later Version : 0.1.0
Adapter thin entre @mostajs/booking et @mostajs/payment (multi-provider Stripe / Chargily / Satim CIB / PayPal). Pas de duplication de logique paiement — juste la glue :
- Hook
afterReservecrée un checkout, stocke l'URL dansreservation.metadata.paymentUrl - Webhook handler vérifie signature + confirme la reservation au paiement réussi
- Hook
beforeCancellance refund automatique selon la cancellation window de la resource
Table des matières
- Architecture
- Quick start
- API
- Refund policies
- Multi-provider (devise auto)
- Webhook routing
- Patterns
- Limites v0.1
- Modules liés
1. Architecture
POST /api/booking/reserve
│
▼
┌──────────────────────────────────────────────┐
│ manager.reserve({ slotId, userId, ... }) │
│ → reservation status='pending' │
│ → fires afterReserve hook │
└────────────────────────┬─────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ adapter.afterReserve (booking-payment hook) │
│ → resolveAmount(reservation, slot, ...) │
│ → provider.createCheckout(...) │
│ → reservation.metadata.paymentUrl = URL │
└──────────────────────────────────────────────┘
│
▼
App reads metadata.paymentUrl, redirects user to Stripe
│
▼
USER PAYS ON STRIPE / CHARGILY
│
▼
POST /api/booking/payment/webhook (Stripe)
│
▼
┌──────────────────────────────────────────────┐
│ adapter.webhookHandler(req) │
│ → provider.verifyWebhook(body, signature) │
│ → event.type === 'checkout.session.completed'?
│ → manager.confirmReservation(reservationId)│
│ → reservation.status = 'confirmed' │
└──────────────────────────────────────────────┘Pour les cancellations :
manager.cancelReservation(id, reason)
→ beforeCancel hook
→ isRefundable(reservation, slot, resource) ?
→ provider.createRefund(paymentSessionId)
→ reservation.metadata.refundId / refundedAmount2. Quick start
Installation
npm install @mostajs/booking @mostajs/payment @mostajs/booking-paymentSetup avec Stripe
import { createBookingManager } from '@mostajs/booking'
import { createBookingPayment } from '@mostajs/booking-payment'
import { stripeProvider } from '@mostajs/payment' // ou un provider custom
const stripe = stripeProvider({
secretKey: process.env.STRIPE_SECRET!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
})
const adapter = createBookingPayment({
manager,
provider: stripe,
// Calcule le montant selon resource.kind (médical = gratuit, resto = deposit fixe, etc.)
resolveAmount: (reservation, slot, resource) => {
if (resource?.kind === 'doctor-visit') return null // gratuit, géré par sécu
if (resource?.kind === 'restaurant-table')
return { amount: 1000, currency: 'EUR', description: 'Deposit restaurant' }
if (resource?.kind === 'event')
return { amount: (resource.metadata as any)?.price ?? 2000, currency: 'EUR' }
return null
},
successUrl: r => `https://app.example.com/booking/${r.id}/confirmed`,
cancelUrl: r => `https://app.example.com/booking/${r.id}/cancelled`,
webhookUrl: 'https://app.example.com/api/booking/payment/webhook',
refundPolicy: 'window-based', // refund si dans la cancellationWindow.freeUntilMs de la resource
})
// Plug les hooks
const manager = createBookingManager({
// ...
hooks: adapter.hooks,
})Route webhook Next.js
// app/api/booking/payment/webhook/route.ts
import { adapter } from '@/lib/booking-payment'
export async function POST(req: Request) {
return adapter.webhookHandler(req, { signatureHeader: 'stripe-signature' })
}Flow côté app
// app/api/booking/slots/[slotId]/reserve/route.ts
import { manager } from '@/lib/booking'
export async function POST(req: Request, { params }) {
const { slotId } = await params
const session = await getServerSession()
const reservation = await manager.reserve({ slotId, holderUserId: session.user.id })
// adapter.afterReserve a enrichi metadata.paymentUrl automatiquement
const paymentUrl = (reservation.metadata as any)?.paymentUrl
if (!paymentUrl) return Response.json(reservation) // gratuit
return Response.json({ ...reservation, redirectTo: paymentUrl })
}3. API
createBookingPayment(opts) → BookingPaymentAdapter
interface BookingPaymentOptions {
manager: BookingManager
provider: BookingPaymentProvider // de @mostajs/payment
/** Resolveur du montant (retourne null pour skip paiement). */
resolveAmount: (reservation, slot, resource) =>
{ amount: number; currency?: string; description?: string } | null
/** URLs success / cancel (string template avec {reservationId} OU fonction). */
successUrl: string | ((reservation) => string)
cancelUrl: string | ((reservation) => string)
/** URL absolue du webhook (envoyé au provider). */
webhookUrl?: string
/** Politique de remboursement. Default 'window-based'. */
refundPolicy?: 'always' | 'window-based' | 'never' | callback
defaultCurrency?: string // default 'EUR'
autoConfirmOnPayment?: boolean // default true
// Callbacks
onCheckoutCreated?: (r, url, sessionId) => void
onPaymentSucceeded?: (r, sessionId) => void
onPaymentFailed?: (r, reason) => void
onRefunded?: (r, refundId, amount) => void
onError?: (where, error) => void
}interface BookingPaymentAdapter {
hooks: BookingHooks // à passer à createBookingManager({ hooks })
/** Trigger manuel d'un checkout (si app a créé la reservation séparément). */
startCheckout(reservationId: string): Promise<{ url: string; sessionId: string } | null>
/** Web-standard handler pour le webhook provider. */
webhookHandler(req: Request, opts?: { signatureHeader?: string }): Promise<Response>
/** Refund manuel (e.g. depuis admin UI). */
refund(reservationId: string, opts?: { amount?: number; reason?: string }):
Promise<{ id: string; amount: number; status: string } | null>
}BookingPaymentProvider interface
Sous-ensemble de PaymentProvider de @mostajs/payment que l'adapter consomme :
interface BookingPaymentProvider {
createCheckout(params): Promise<{ url: string | null; sessionId: string }>
verifyWebhook(body: string, signature: string): Promise<{ type: string; data: Record<string, unknown> }>
createRefund?(params): Promise<{ id: string; amount: number; status: string }>
}Tout provider qui implémente cette interface marche : Stripe, Chargily, Satim CIB, PayPal (tous fournis par @mostajs/payment), ou un provider custom (test, sandbox, mock).
4. Refund policies
'window-based' (default)
Refund si slot.startAt - now > resource.policy.cancellationWindow.freeUntilMs. Exemple :
const restaurantTable = await manager.createResource({
kind: 'restaurant-table',
// ...
policy: { cancellationWindow: { freeUntilMs: 2 * 3600_000 } }, // 2h
})Annulation > 2h avant le slot → refund. Annulation tardive → no refund (deposit conservé).
'always'
Refund systématique. Cas d'usage : promotions, gestes commerciaux par défaut.
'never'
Pas de refund. Cas : Eventbrite tickets (achat ferme).
Custom callback
refundPolicy: async (reservation, slot, resource) => {
// Logique métier custom : refund si user a un compte VIP, ou si moins de X cancellations dans l'année, etc.
const user = await usersRepo.findById(reservation.holderUserId!)
if (user?.tier === 'vip') return true
const recentCancels = await reservationsRepo.count({
holderUserId: user.id,
status: 'cancelled',
cancelledAt: { $gt: Date.now() - 365 * 86400_000 },
})
return recentCancels < 3
},5. Multi-provider (devise auto)
@mostajs/payment v0.5 expose pickProviderByCurrency :
import { pickProviderByCurrency } from '@mostajs/payment/lib/webhook-helpers'
const adapter = createBookingPayment({
manager,
// Provider dynamic : pour DZD utilise Chargily, sinon Stripe
provider: {
async createCheckout(params) {
const { provider } = pickProviderByCurrency(params.currency)
return provider.createCheckout(params)
},
async verifyWebhook(body, signature) {
// l'app doit savoir quel provider, via discriminator dans le path ou header
throw new Error('Use provider-specific webhook routes for multi-provider')
},
},
// ...
})Pour le webhook multi-provider, mieux : N routes Next, une par provider :
/api/booking/payment/webhook/stripe→adapterStripe.webhookHandler(req)/api/booking/payment/webhook/chargily→adapterChargily.webhookHandler(req, { signatureHeader: 'signature' })
Avec 2 instances d'adapter, chacune avec son provider.
6. Webhook routing
Stripe
export async function POST(req: Request) {
return adapter.webhookHandler(req) // signatureHeader = 'stripe-signature' par défaut
}Chargily
export async function POST(req: Request) {
return adapterChargily.webhookHandler(req, { signatureHeader: 'signature' })
}Events Stripe gérés
| Event | Action |
|---|---|
| checkout.session.completed | manager.confirmReservation(reservationId) |
| payment_intent.succeeded | idem |
| payment_intent.payment_failed | manager.cancelReservation(id, 'payment-failed') |
| checkout.session.expired | idem |
| autres | ignore (200) |
L'identification de la reservation se fait via metadata.reservationId injecté dans createCheckout.
7. Patterns
Pattern A — Booking gratuit + payant mixés
resolveAmount: (reservation, slot, resource) => {
// null = pas de paiement (médical, cours gratuit, etc.)
if (resource?.metadata?.price == null || resource.metadata.price === 0) return null
return { amount: resource.metadata.price as number, currency: 'EUR' }
},Pattern B — Pricing dynamique (depuis @mostajs/booking-pricing futur)
import { calculatePrice } from '@mostajs/booking-pricing'
resolveAmount: async (reservation, slot, resource) => {
const price = await calculatePrice({ reservation, slot, resource })
if (!price) return null
return { amount: price.amount, currency: price.currency, description: price.label }
},Pattern C — Frais d'annulation tardive (no full refund)
refundPolicy: async (reservation, slot, resource) => {
const ttl = slot.startAt - Date.now()
const window = resource?.policy?.cancellationWindow?.freeUntilMs ?? 0
if (ttl > window) return true // refund full
if (ttl > window / 2) {
// refund partial via adapter.refund(id, { amount: original / 2 })
// (logique custom à mettre dans la route /cancel côté app)
return false // skip auto-refund, app gère manual
}
return false // forfeit total
},Pattern D — Tests sans Stripe (mock provider)
const mockProvider: BookingPaymentProvider = {
async createCheckout(params) {
return { url: `http://localhost:3000/mock-pay/${params.orderId}`, sessionId: `mock_${params.orderId}` }
},
async verifyWebhook(body, _sig) {
return JSON.parse(body)
},
async createRefund(params) {
return { id: `mock_refund_${Date.now()}`, amount: params.amount ?? 0, status: 'succeeded' }
},
}Pattern E — Audit / Prometheus
const adapter = createBookingPayment({
// ...
onCheckoutCreated: (r, url, sid) => metrics.increment('payment.checkout', { resource: r.resourceId }),
onPaymentSucceeded: (r) => metrics.increment('payment.succeeded', { resource: r.resourceId }),
onPaymentFailed: (r, reason) => metrics.increment('payment.failed', { reason }),
onRefunded: (r, _, amt) => metrics.observe('payment.refund.amount', amt),
onError: (where, err) => sentry.captureException(err, { tags: { where } }),
})8. Limites v0.1
- Pas de partial refund automatique : utiliser
adapter.refund(id, { amount })manuellement reservation.metadata.paymentUrlset in-place : pour persister en DB, l'app doit appeler son repo.save() après reserve. Le manager core ne réexpose pasupdateReservationdirectement (à fixer dans booking v0.2)- Pas de retry automatique sur webhook (Stripe retry side, à configurer côté Stripe Dashboard)
- Pas d'idempotence sur double webhook (Stripe envoie parfois 2× le même event). Le
manager.confirmReservationlui-même est idempotent (rejette si status ≠ 'pending'), donc OK en pratique - Refund window basée uniquement sur
resource.policy.cancellationWindow.freeUntilMs: pour des windows plus complexes (échelonnée), utiliser refundPolicy callback
9. Modules liés
@mostajs/booking— source des reservations@mostajs/payment— providers Stripe/Chargily/Satim/PayPal (peer dep)@mostajs/booking-notifications— envoyer un mail post-paiement (eventreservation.confirmedchained)@mostajs/booking-pricing— futur, calcul dynamique de prix@mostajs/auth— extraire holderUserId
License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]
