Aller au contenu

Checkout

Contrôleur HTTP du flux de money-in par carte via Checkout.com. Source : checkout.controller.ts.

Pour le flux métier complet (cycle de vie des WalletTransaction, rapprochement bancaire des wire-ins, P2P Lemonway), voir le service checkout.service.ts.

Un endpoint expose la création de session de paiement (auth investisseur + rôle CUSTOMER). Les trois autres sont des webhooks appelés par Checkout.com et sécurisés par API key (x-api-key header).

POST /checkout/create-payment-session

Crée une session de paiement Checkout.com pour un montant donné. Renvoie le secret/token nécessaires au SDK front pour ouvrir la modale de paiement carte.

Le payload Checkout est construit côté serveur depuis le profil investisseur (buildCheckoutPaymentSessionPayload) — adresse, type (individual / corporate), date de naissance, téléphone, etc. Une WalletTransactionId est générée à l'avance (reference envoyée à Checkout) pour pouvoir corréler ensuite le webhook avec la WT à créer.

Pré-conditions

  • Session investisseur valide (JwtAuthGuard)
  • Rôle CUSTOMER (UserRoleGuard)
  • Droits transactionnels actifs : Customer.assertTransactionRights(['all'])
  • Profil investisseur (CustomerProfileRepository.findOneOrFail) présent — sinon 500
  • Pour le Canada et les USA, regionCode (province ou état, code à 2 lettres) doit être renseigné sur le profil

Request body

{
  "amount": 5000,
  "referer": "https://app.bricks.co/topup",
  "searchParams": "utm_source=email",
  "bricksReservationId": "019abc..."
}

Pas d’union discriminée côté wire — kind n’existe pas dans le body, pour rester compatible avec les clients mobile déjà en store (top-up sans champ extra). La distinction top-up / bricks purchase se fait par présence de bricksReservationId.

  • amount — montant en cents (Cents). La session Checkout n'applique pas de plafond. Les 10 000 € restent sur Lemonway CB, la carte cadeau, la réservation d'achat de bricks (POST /primary-purchase) et le top-up (front). La levée ne filtre pas ce plafond.
  • referer — URL absolue du front qui ouvre la session (optionnel)
  • searchParams — query string transmise à Checkout (optionnel)
  • bricksReservationId — optionnel. Si présent : exige le flag ENABLE_CHECKOUT_CARD_BRICKS_PURCHASE, ownership/status validés, et amount doit couvrir au minimum le money-in requis (purchase.value − amountToUseFromGiftBalance − availableWithdrawable). Un montant plus élevé est accepté (ex. wallet non utilisé côté front). Au payment_approved, setCardInForBricksReservation attache la WT. Lemonway CB reste le chemin par défaut côté front tant que le flag est OFF. Les URLs de retour portent checkout_flow=bricks_purchase (via searchParams front) pour rouvrir PurchaseResultModal (même UI succès que Lemonway).

Validé via createPaymentSessionBodyValidator (depuis @bricks-common/api-communication, paymentSession.ts).

Response

{
  "id": "ps_xxx",
  "payment_session_secret": "sk_xxx",
  "payment_session_token": "tok_xxx",
  "_links": {
    "self": { "href": "https://api.checkout.com/payment-sessions/ps_xxx" }
  }
}

Réponse passée telle quelle depuis Checkout. Checkout peut renvoyer des champs extra (Flow SDK) : createPaymentSessionResponseValidator est un intersection avec un dictionary(string, unknown) pour les conserver.

Erreurs

Code Statut Cause
customer.rights.account-is-being-verified 403 Compte Lemonway bloqué ou droits insuffisants
customer.rights.account-not-completed 403 Identité investisseur non vérifiée
checkout.customer.state_required 400 Code région manquant pour le Canada ou les USA
checkout.create_payment_session.error 400 Erreur retournée par l'API Checkout.com lors de la création de session
checkout.bricks-purchase-not-enabled 400 bricksReservationId présent alors que ENABLE_CHECKOUT_CARD_BRICKS_PURCHASE est OFF (ou minVersion non respectée via header x-version)
checkout.bricks-reservation-amount-too-low 400 amount < money-in minimum pour la réservation bricks
primary-purchase-not-found / wrong-investor / wrong-status / no-reservation 400 bricksReservationId invalide pour l'investisseur
session_expired 401 Session investisseur invalide

Code région manquant (onboarding)

Lors de la sauvegarde d'une adresse d'onboarding pour le Canada ou les USA, si regionCode reste absent après sanitization, InvestorOnboardingService logue une erreur et envoie une alerte Slack via CheckoutSlackService.sendMissingCheckoutRegionCodeAlert :

Champ alerte Valeur
Canal Slack (prod) #checkout-double-paiement
Canal Slack (hors prod) devChannelNames[environment] (internal-development-alerts, internal-staging-alerts, internal-local-alerts)
Mention @sos_captain (<!subteam^S05BL6SCWMD>)
Action attendue Retrouver le code province (CAN) ou état (USA) à partir de l'adresse et l'ajouter sur le customer-profile en BO — sans ce champ, le customer ne peut pas payer par carte (Checkout). Une suggestion Mapbox indicative peut être incluse dans l'alerte (à valider manuellement)

POST /checkout/webhook/payment-approved

Webhook appelé par Checkout.com quand un paiement carte est approuvé. Crée (ou met à jour) la WalletTransaction de type TOPUP_CHECKOUT au statut WAITING côté Bricks. Le P2P Lemonway sera ensuite créé via le rapprochement wire-in (cf. processCheckoutWireIn).

Gère le cas Apple Pay où plusieurs tentatives peuvent partager la même session (et donc la même reference/wtId) : la WT existante est repassée en WAITING avec le nouveau checkoutPaymentId.

Si udf2 (réservation bricks) est présent, setCardIn est tenté après le commit de la WT. Un échec d'attach ne rollback pas le TOPUP_CHECKOUT (Checkout a déjà capturé) — log + alerte Slack ; retry / ops manuelle.

Double paiement (même session, deux captures)

Si une WT TOPUP_CHECKOUT existe déjà avec un checkoutPaymentId différent du webhook entrant (et n'est pas DECLINED), le service logue un warning et envoie une alerte Slack dédiée après commit via CheckoutSlackService.sendDoublePaymentAlert :

Champ alerte Valeur
Canal Slack (prod) #checkout-double-paiement
Canal Slack (hors prod) devChannelNames[environment] (internal-development-alerts, internal-staging-alerts, internal-local-alerts)
Paiement conservé incomingCheckoutPaymentId — écrase le contexte WT (last-write-wins)
Paiement à rembourser existingCheckoutPaymentId — lien cliquable vers le dashboard Checkout (CHECKOUT_DASHBOARD_URL)

Runbook support : skill sos-diagnostic-double-checkout.

Pré-conditions

  • Header x-api-key valide (ApiAuthService.assertApiKeyInHeaders('checkout'))

Request body

Validé via CheckoutWebhook.paymentApprovedValidator (checkout-webhook.model.ts).

{
  "id": "evt_xxx",
  "type": "payment_approved",
  "version": "1.0.40",
  "created_on": "2026-05-07T12:34:56Z",
  "data": {
    "id": "pay_xxx",
    "action_id": "act_xxx",
    "reference": "<walletTransactionId>",
    "amount": 5000,
    "currency": "EUR",
    "customer": { "id": "cus_xxx", "email": "..." },
    "metadata": { "udf1": "<customer-uuid>" },
    "source": { "card_wallet_type": "card", "scheme": "Visa", "last_4": "4242" },
    "risk": { "flagged": false, "score": 2 },
    "..."
  }
}

metadata.udf1 doit contenir l'UUID du customer (sinon le service retourne customer-id-not-found).

Response

200 OK sans corps en cas de succès.

Erreurs

Code Statut Cause
Unauthorized 401 API key absente ou invalide
customer-id-not-found 500 metadata.udf1 manquant dans le payload Checkout
wt-not-checkout 500 Une WT existe déjà avec cette reference mais n'est pas de type TOPUP_CHECKOUT (intégrité données)

Toute erreur du service est loggée et alertée sur Slack via InternalAlertSlackService.alert avant de remonter en 500.

POST /checkout/webhook/payment-declined

Webhook appelé par Checkout.com quand un paiement carte est refusé. Crée une WalletTransaction de type TOPUP_CHECKOUT au statut DECLINED (si aucune WT n'existe encore pour cette reference).

Gère le retry sur la même session (timeout / Apple Pay) : - WT déjà WAITING (ou autre non-DECLINED) avec le même checkoutPaymentId → erreur wt-not-declined - WT non-DECLINED avec un autre checkoutPaymentId → no-op (decline stale après un approve) - Pas d'expiry forcée de la réservation bricks (udf2) : un decline n'est pas terminal, un payment_approved peut encore arriver. La réservation expire via son expireAt naturel (front 5 min / back 10 min).

Pré-conditions

  • Header x-api-key valide (ApiAuthService.assertApiKeyInHeaders('checkout'))

Request body

Validé via CheckoutWebhook.paymentDeclinedValidator (checkout-webhook.model.ts).

Même schéma que payment-approved avec type: "payment_declined".

Response

200 OK sans corps en cas de succès.

Erreurs

Code Statut Cause
Unauthorized 401 API key absente ou invalide
customer-id-not-found 500 metadata.udf1 manquant dans le payload Checkout
wt-not-checkout 500 Une WT existe déjà avec cette reference mais n'est pas de type TOPUP_CHECKOUT
wt-not-declined 500 WT existante avec même checkoutPaymentId n'est pas en DECLINED

Toute erreur du service est loggée et alertée sur Slack via InternalAlertSlackService.alert avant de remonter en 500.

POST /checkout/webhook/dispute-received

Webhook appelé par Checkout.com quand une dispute (chargeback carte) est reçue (dispute_received). À ce stade Checkout a reçu la notification du réseau et est encore en revue : l'API n'écrit rien en base. Elle envoie uniquement une alerte Slack pour que le support aille consulter la dispute sur le dashboard Checkout.

Le payload officiel (dispute_received) ne contient pas d'URL dashboard. _links.self pointe vers l'API workflow de l'événement, pas vers le Hub. La dispute se consulte sur la fiche du paiement. Le lien Slack reprend le même pattern que le double paiement : {CHECKOUT_DASHBOARD_URL}/payments/all-payments/payment/{payment_id}.

L'id de dispute (dsp_…) est dans le message.

À configurer côté Checkout : workflow / webhook dispute_received vers cet endpoint, même x-api-key que payment-approved / payment-declined.

Pré-conditions

  • Header x-api-key valide (ApiAuthService.assertApiKeyInHeaders('checkout'))

Request body

Validé via CheckoutWebhook.disputeReceivedBodySchema (zod, checkout-webhook.model.ts).

{
  "type": "dispute_received",
  "data": {
    "id": "dsp_xxx",
    "category": "fraudulent",
    "amount": 8150,
    "currency": "EUR",
    "reason_code": "10.4",
    "payment_id": "pay_xxx",
    "payment_reference": "<walletTransactionId>",
    "payment_method": "VISA"
  }
}

Le validator ne parse que les champs utilisés pour Slack. data.id (dispute) et data.payment_id sont requis. Le reste du payload Checkout est ignoré.

data.category est l'enum API Checkout : canceled_recurring, credit_not_issued, fraudulent, general, incorrect_amount, duplicate, not_as_described, product_service_not_received (dispute reasons). Une valeur hors de cette liste (ou une catégorie ajoutée plus tard par Checkout) fait échouer le validator en 400, donc pas d'alerte Slack.

Response

201 Created sans corps en cas de succès.

Alerte Slack

Champ alerte Valeur
Canal Slack (prod) #checkout-double-paiement
Canal Slack (hors prod) devChannelNames[environment] (internal-development-alerts, internal-staging-alerts, internal-local-alerts)
Mention @sos_captain (<!subteam^S05BL6SCWMD>)
Action attendue Consulter la dispute sur Checkout (fiche du paiement)
Lien Fiche du paiement {CHECKOUT_DASHBOARD_URL}/payments/all-payments/payment/{payment_id}

Erreurs

Code Statut Cause
Unauthorized 401 API key absente ou invalide

Cron sync-checkout-topup-report

Graphile task sync-checkout-topup-report (0 */6 * * *) — rapproche les top-ups carte Checkout avec les rapports financiers Checkout (FinancialActionsByPayout).

Source : sync-checkout-topup-report.cron-task.ts.

Paramètre Valeur
Lookback 7 jours calendaires (CHECKOUT_REPORT_LOOKBACK_DAYS = 7, createdAfter = startOfDay(now − 7d)) — filet si un rapport quotidien a été manqué
Filtre Rapports FinancialActionsByPayout non encore présents en DB (checkout_financial_report)
Ordre created_on croissant — les rapports les plus anciens non traités passent en premier
Effet Télécharge le CSV, crée les TOPUP_CHECKOUT manquants / aligne les captures, puis marque le rapport traité

En échec de fetch Checkout → alerte Slack (InternalAlertSlackService) + throw Graphile pour retry. Runbook jobs : skill sos-graphile-jobs.

Liens