ALLOTATA_STRIPE_NEW_MODE_INVISIBLE
Allotata – Stripe en mode « paiement invisible »
Ce document décrit le fonctionnement du service de paiement Stripe dans son mode « terminal invisible » : aucun redirect Stripe Checkout, tout reste sur le site. Stripe sert uniquement de terminal de débit ; la logique métier (prix, échéances, réductions) est gérée dans Laravel.
1. Principes généraux
1.1 Modèle « Terminal invisible »
- Pas de redirection vers une page Stripe (Checkout Session classique). L’utilisateur reste sur
/checkout(Espace Paiement). - Stripe Elements (Payment Element) : capture des données carte dans nos formulaires, iframes Stripe intégrées au design.
- Off-session : on enregistre un PaymentMethod (PM) puis on débite côté serveur via l’API Stripe, sans action de l’utilisateur au moment du prélèvement.
- Pas de Plans/Produits Stripe pour les abonnements : les montants sont calculés en PHP (Tarif, CustomPrice, promo) ; Stripe reçoit uniquement des ordres de débit pour des montants variables.
1.2 Flux en deux temps
- Enregistrement carte (SetupIntent)
L’utilisateur va sur/checkout, remplit le formulaire Stripe, clique « Enregistrer ma carte ». Aucun débit. On stocke le PaymentMethod ID (pm_xxx) et on le rattache au Customer Stripe. - Débit (PaymentIntent)
Plus tard (immédiatement si « Régler » sur une échéance, ou via CRON pour les échéances mensuelles) : le serveur crée un PaymentIntent avecpayment_method,off_session,confirm, et prélève la carte sans interaction utilisateur (sauf 3D Secure si requis).
1.3 Rôles des objets Stripe
| Objet | Rôle |
|-------|------|
| Customer | Un par User. user.stripe_id = cus_xxx. Créé à la volée si absent. |
| SetupIntent | Créer un PaymentMethod sans débiter. usage: off_session. |
| PaymentMethod | Carte (ou autre) attachée au Customer. ID stocké dans user.stripe_payment_method_id. |
| PaymentIntent | Ordre de débit. Créé côté serveur avec customer, payment_method, off_session, confirm. |
| Checkout Session | Utilisé uniquement pour le flux legacy « Paiement test » (redirect). Pas pour le flux invisible. |
2. Configuration
2.1 Variables d’environnement
STRIPE_KEY=pk_test_... # Clé publique (front)
STRIPE_SECRET=sk_test_... # Clé secrète (back)
STRIPE_WEBHOOK_SECRET=whsec_...
CRON_SECRET=... # Token secret pour /cron-run (voir § 5.4)
config('services.stripe.key')etconfig('services.stripe.secret')alimentent Stripe PHP et le front.- Le webhook doit pointer vers votre handler (ex.
/stripe/webhook) et envoyer au moinspayment_intent.succeededpour la réconciliation. - CRON_SECRET : valeur aléatoire sécurisée (ex.
openssl rand -hex 32). Protège la route/cron-runqui lance les tâches planifiées. Ne pas utiliserchange-me-in-production.
2.2 Base de données
- Users :
stripe_id,stripe_payment_method_id,pm_type,pm_last_four. - Echeances :
stripe_checkout_session_id,stripe_payment_intent_id,statut,paye_at, etc. - stripe_transactions : journal des événements Stripe (payment_intent.succeeded, etc.).
- payment_audit_log : audit détaillé (setup, save PM, charge, 3DS, IP, user-agent, etc.).
Migrations à exécuter : php artisan migrate (dont payment_audit_log, stripe_transactions, colonnes Stripe sur users).
3. Adresse de facturation et Payment Element
3.1 address: 'if_required'
On configure le Payment Element avec :
elements.create('payment', {
fields: {
billingDetails: {
address: 'if_required',
},
},
});
- Stripe affiche dans son formulaire les champs d’adresse requis pour le moyen de paiement (carte : typiquement code postal, parfois ville). Pas de formulaire custom côté app.
- L’utilisateur saisit code postal et éventuellement ville directement dans l’iframe Stripe. Aucun
payment_method_data.billing_detailsn’est envoyé àconfirmSetup; Stripe gère la collecte et l’envoi.
3.2 Profil utilisateur (Paramètres → Compte)
Ville et code postal restent stockés dans le profil (Paramètres → Mon compte) pour l’affichage des adresses et la facturation hors Stripe. Ils ne sont pas utilisés pour le flux d’enregistrement de carte : la saisie se fait dans le Payment Element.
4. Parcours utilisateur
4.1 Accès au checkout
- Espace Paiement : lien dans la nav (desktop) et le burger (mobile) →
/checkout. - Depuis l’onglet Abonnement (Paramètres / Dashboard) : « Payer maintenant », « Modifier la carte » →
/checkout?change_card=1, « Ajouter une carte », « Régler », « Voir toutes les échéances et payer » →/checkout.
4.2 Enregistrement d’une carte (SetupIntent)
- Page
/checkout, section « Moyen de paiement » (affichée si pas de carte ou si?change_card=1). - Chargement : fetch
POST /checkout/setup-intent→ le serveur crée un SetupIntent (usage: off_session,payment_method_types: ['card']), renvoieclient_secret. - Stripe Elements : création du Payment Element avec ce
client_secret,address: 'if_required', et theme night/stripe selon le mode sombre/clair. Stripe affiche les champs nécessaires (carte + code postal, etc.) dans son formulaire. - Clic « Enregistrer ma carte » (ou « Remplacer la carte » en mode changement) :
stripe.confirmSetup({ elements, confirmParams: { return_url } }). Aucunpayment_method_data.billing_details: Stripe collecte l’adresse dans l’Element.- Si 3DS : redirect Stripe puis retour sur
/checkout?setup_intent_client_secret=...&redirect_status=succeeded. Le JS appelleretrieveSetupIntent, récupère lepayment_method, puisPOST /checkout/save-payment-methodavec{ payment_method: 'pm_xxx' }. - Sinon : pas de redirect ; même appel à
save-payment-methodaprèsconfirmSetup.
- Côté serveur (
savePaymentMethod) :- Attache le PM au Customer Stripe (
StripeCustomerService::attachPaymentMethod). - Met à jour
user.stripe_payment_method_id,pm_type,pm_last_four. - Log audit
save_pm_ok(ousave_pm_failen cas d’erreur).
- Attache le PM au Customer Stripe (
4.3 Changer ou supprimer la carte
- Changer la carte : lien « Changer la carte » sur
/checkout(ou « Modifier la carte » depuis Abonnement) →/checkout?change_card=1. Le formulaire d’enregistrement réapparaît ; l’utilisateur saisit une nouvelle carte. Après enregistrement, redirection vers/checkoutsans paramètre. Boutons « Annuler » / « ← Annuler » pour revenir sans modifier. - Supprimer la carte : bouton « Supprimer la carte » sur
/checkout→POST /checkout/remove-payment-method(avec confirmation). Le serveur détache le PaymentMethod chez Stripe, videinvoice_settings.default_payment_methodsi besoin, met ànullstripe_payment_method_id,pm_type,pm_last_four, logremove_pm_ok, puis redirige vers/checkoutavec « Carte supprimée. »
4.4 Paiement d’une échéance (PaymentIntent)
- Prérequis : carte déjà enregistrée (
user.stripe_payment_method_idprésent). - Sur
/checkout, l’utilisateur clique « Régler » sur une échéance (ou « Régler cette échéance »). - Front :
POST /checkout/chargeavec{ echeance_id, code_promo? }. - Back (
charge) :- Vérifie l’échéance (statut
a_payerouen_attente, appartenance au user). - Calcule le montant (
CalculMontantDuService::calculerPourEcheance). - Si montant ≤ 0 ou pas de PM → 422/409 et log
charge_fail. - Sinon :
PaymentIntent::createaveccustomer,payment_method,off_session,confirm: true,metadata: { user_id, echeance_id }.
- Vérifie l’échéance (statut
- Réponses :
succeeded: on met à jour l’échéance (statutpaye,paye_at, etc.), on appellePaymentVerificationService::markEcheancePaidFromPaymentIntent, on crée uneStripeTransaction, on logcharge_ok. Réponse{ success: true }.requires_action(3DS) : on met l’échéance enen_attente, on stockestripe_payment_intent_id, on logcharge_3ds. Réponse{ requires_action: true, client_secret, payment_intent_id }.
- Si 3DS : le front appelle
stripe.handleCardAction(client_secret). Après succès,POST /checkout/confirm-statusavecpayment_intent_id. Le serveur appelle à nouveaumarkEcheancePaidFromPaymentIntent, met à jour l’échéance, logconfirm_status_ok.
4.5 Codes promo
- Application : formulaire sur
/checkoutou paramètre de requête. Le code est stocké en session (checkout_promo_code) et/ou envoyé danscharge. - Calcul :
CalculMontantDuService::calculerPourEcheanceutilisePromoCode::validateCodeet applique la réduction. Le montant final peut être 0 (refusé en charge avec un message dédié).
5. Vérification et réconciliation des paiements
5.1 Triple niveau de robustesse
- Webhook
payment_intent.succeeded(et éventuellementcheckout.session.completedpour le legacy) : mise à jour des échéances et création deStripeTransaction. Peut échouer (réseau, erreur 5xx). - Vérification directe Stripe : après 3DS, le front appelle
confirm-status; le serveur fait unPaymentIntent::retrieveet marque l’échéance payée sistatus === 'succeeded'. - CRON
subscriptions:reconcile-echeances: pour les échéancesen_attenteavecstripe_checkout_session_id, on interroge Stripe (Session ou PI selon le flux) et on met à jour le statut. Si le paiement n’est pas confirmé, on repasse ena_payerpour retry.
5.2 PaymentVerificationService
verifyAndMarkPaid(sessionId): flux Checkout Session. Récupère la session, vérifiepayment_status === 'paid', extraituser_idetecheance_iddes metadata, met à jour l’échéance, crée uneStripeTransactionsi besoin, gère les abonnements entreprise.markEcheancePaidFromPaymentIntent(piId): flux PaymentIntent (charge Elements). Récupère le PI, vérifiestatus === 'succeeded', idem metadata et mise à jour. Idempotent : si l’échéance est déjà payée, ne fait rien.ensureStripeTransactionFromPaymentIntent: crée une entréestripe_transactionsà partir du PI (event_typepayment_intent.succeeded,processed: true).
5.3 Commandes CRON
subscriptions:check-echeances: génère les échéances à venir (Premium, options entreprise) selon les dates de facturation. Planifié quotidiennement.subscriptions:reconcile-echeances: réconcilie les échéancesen_attente. Note : la commande actuelle cible celles avecstripe_checkout_session_id. Les échéances mises en attente par le flux PaymentIntent (3DS) n’ont questripe_payment_intent_id; une évolution pourrait étendre la réconciliation à ces cas (vérification du PI sur Stripe).essais:check-expiration: vérifie les essais gratuits, envoie rappels et notifications d’expiration.
5.4 Déclenchement HTTP : /cron-run
Pour éviter d’utiliser docker exec ou un cron Laravel dans un conteneur, une route HTTP permet de lancer les trois commandes ci‑dessus.
Configuration
.env:CRON_SECRET=<token-secret-long>(ex. généré avecopenssl rand -hex 32).
Utilisation
| Usage | Méthode |
|-------|---------|
| Manuel | Ouvrir dans le navigateur : https://votre-domaine.fr/cron-run?token=VOTRE_CRON_SECRET |
| Cron externe | curl -s "https://votre-domaine.fr/cron-run?token=VOTRE_CRON_SECRET" |
| Header | X-Cron-Token: VOTRE_CRON_SECRET (GET ou POST) |
Comportement
- Vérification du token (query
tokenou headerX-Cron-Token). Si invalide ou absent → 403. SiCRON_SECRETmanquant ou égal àchange-me-in-production→ 500. - Exécution séquentielle de :
subscriptions:check-echeances,subscriptions:reconcile-echeances,essais:check-expiration. - Réponse JSON :
success,message,results(sortie de chaque commande),at(ISO 8601). En cas d’échec d’une commande →success: falseet HTTP 500.
Exemple de crontab (hébergeur, sans Docker)
0 6 * * * curl -s "https://allotata.fr/cron-run?token=VOTRE_CRON_SECRET"
Aucun php artisan, ni docker exec : un simple curl sur l’URL suffit.
6. Échéances, tarifs et calcul des montants
6.1 Modèle Echeance
- Statuts :
a_payer,en_attente,paye,echec,annule,arrete. - Types :
default(Premium),site_web,multi_personnes(options entreprise). - Champs clés :
user_id,entreprise_id,subscription_type,periode_debut/periode_fin,montant_du,montant_final,reduction_promo,reduction_manuel,promo_code_id,stripe_payment_intent_id,stripe_checkout_session_id,paye_at,statut.
6.2 Calcul du montant
CalculMontantDuService::calculerPourEcheance:- Base = tarifs (Tarif, CustomPrice selon user/entreprise).
- Applique les codes promo (
PromoCode::validateCode). - Applique les réductions manuelles (gestes commerciaux) sur l’échéance.
- Retour :
montant_du,montant_final,reduction_promo,lignes,promo_code_id. Le montant affiché et débité estmontant_final.
6.3 Tarifs
- Tarif : modèle en BDD (type, amount, currency, label). Les prix sont éditables dans l’admin (ex. onglet Prix Stripe). Au moment du paiement, le tarif est « figé » pour l’échéance ; les changements futurs de tarif n’impactent pas les échéances déjà créées.
7. Audit et traçabilité
7.1 payment_audit_log
Chaque action sensible est enregistrée : user_id, action, IDs Stripe (customer, PI, PM, etc.), amount, currency, status, ip_address, user_agent, request_id, context (JSON), message.
Actions loguées :
setup_intent_created,save_pm_ok,save_pm_fail,remove_pm_okcharge_ok,charge_fail,charge_3dsconfirm_status_ok,confirm_status_fail
Consultation : Admin → Paiements → « Journal d’audit paiements (verbose) » (/admin/payment-audit-log). Filtres par utilisateur, action, Payment Intent, dates.
7.2 stripe_transactions
- Entrées créées depuis les webhooks ou depuis
ensureStripeTransactionFromPaymentIntent/ensureStripeTransaction. - Utilisées pour l’historique « Derniers paiements », les remboursements, etc. On exclut les transactions de montant ≤ 0 et on déduplique avec les échéances payées.
8. Affichage côté utilisateur (Abonnement, Checkout)
8.1 Cartes enregistrées
- Affichage
•••• XXXXet type (Visa, etc.) à partir depm_last_fouretpm_type. - Changer la carte →
/checkout?change_card=1(ou « Modifier la carte » depuis Abonnement). Supprimer la carte →POST /checkout/remove-payment-method(confirmations utilisateur). - « Ajouter une carte » →
/checkout(formulaire affiché lorsqu’aucune carte n’est enregistrée).
8.2 Factures
- Uniquement les factures Stripe
status === 'paid'etamount_paid > 0. Pas de factures à 0 € ou brouillons.
8.3 Derniers paiements
- Combinaison d’échéances payées et de StripeTransaction (hors doublons).
- Exclusion : montant ≤ 0 (pas d’entrées « 0,00 € »).
8.4 Prochains paiements
- Échéances
a_payerouen_attente. - Actions : « Régler » →
/checkout, « Annuler » →POST /abonnement/echeance/{id}/annuler(statut →annule, pas de débit).
9. Gestion des erreurs et UX Checkout
9.1 Setup Intent / chargement du formulaire
- Si
POST /checkout/setup-intentéchoue (500, pas declient_secret) : message d’erreur explicite + bouton « Réessayer » sans recharger la page. - Chargement : spinner « Chargement du formulaire… » pendant le fetch et le mount du Payment Element.
- Double init :
form.dataset.saveCardInitévite d’attacher plusieurs fois les listeners.
9.2 Erreurs Stripe (carte refusée, 3DS, etc.)
- Affichage dans
#checkout-card-errorou via toasts. Pas de redirect Stripe ; tout reste sur le site. - En cas de CardException (fonds insuffisants, refus) : message utilisateur clair et log
charge_faildans l’audit.
9.3 3D Secure
- Si
PaymentIntentretournerequires_action: le front utilisestripe.handleCardAction(client_secret). - Après succès 3DS : appel à
confirm-statuspuis rechargement de la page. L’échéance est marquée payée et uneStripeTransactionest créée.
10. Routes et endpoints
| Méthode | Route | Contrôleur | Rôle |
|--------|-------|------------|------|
| GET | /checkout | CheckoutController@index | Page Espace Paiement |
| POST | /checkout/setup-intent | createSetupIntent | Créer un SetupIntent, retourner client_secret |
| POST | /checkout/save-payment-method | savePaymentMethod | Attacher le PM au Customer, mettre à jour le user |
| POST | /checkout/remove-payment-method | removePaymentMethod | Détacher le PM, vider user (stripe_payment_method_id, etc.) |
| POST | /checkout/charge | charge | Créer un PaymentIntent, débiter (ou retourner 3DS) |
| POST | /checkout/confirm-status | confirmStatus | Après 3DS : vérifier le PI et marquer l’échéance payée |
| POST | /checkout/appliquer-promo | appliquerPromo | Appliquer un code promo (session) |
| POST | /checkout/retirer-promo | retirerPromo | Retirer le code promo |
| POST | /abonnement/echeance/{echeance}/annuler | SubscriptionController@annulerEcheance | Annuler une échéance à venir |
| GET / POST | /cron-run | CronRunController@run | Lancer les tâches planifiées (échéances, réconciliation, essais). Protégé par ?token=CRON_SECRET ou X-Cron-Token. |
11. Fichiers principaux
| Rôle | Fichiers |
|------|----------|
| Checkout (page, formulaire) | resources/views/checkout/index.blade.php, resources/js/checkout.js |
| Contrôleur checkout | app/Http/Controllers/CheckoutController.php |
| Customer Stripe, PM | app/Services/StripeCustomerService.php |
| Vérification / réconciliation | app/Services/PaymentVerificationService.php |
| Calcul des montants | app/Services/CalculMontantDuService.php |
| Échéances | app/Models/Echeance.php |
| Audit | app/Models/PaymentAuditLog.php, app/Http/Controllers/Admin/PaymentAuditLogController.php |
| Abonnement (onglet) | resources/views/partials/settings/subscription-tab.blade.php, SettingsController, DashboardController |
| CRON | app/Console/Commands/CheckEcheancesCommand.php, ReconcileEcheancesCommand.php, CheckEssaisExpiration.php |
| Cron HTTP | app/Http/Controllers/CronRunController.php (route /cron-run) |
12. Subtilités à garder en tête
- Code postal : avec
address: 'if_required', Stripe affiche le champ dans son formulaire. L’utilisateur le saisit directement ; il doit correspondre à celui enregistré auprès de la banque (sinon erreur « Votre numéro de carte et votre code postal ne correspondent pas »). - Réconciliation : le CRON actuel ne réconcilie que les échéances avec
stripe_checkout_session_id. Les échéances 3DS purement PaymentIntent n’ont questripe_payment_intent_id; une extension du CRON pourrait les traiter aussi. - Factures : uniquement
paidetamount_paid > 0. Les factures à 0 € ou « open » ne sont pas affichées. - Derniers paiements : exclusion des montants ≤ 0 (échéances et transactions) pour éviter les lignes « 0,00 € ».
- CSP / Google Pay : le SetupIntent est créé avec
payment_method_types: ['card']pour n’afficher que le formulaire carte. Cela évite le chargement de l’iframepay.google.com(Google Pay) et les violations CSP « frame-ancestors » qui peuvent bloquer la saisie carte. Pas de Google Pay côté checkout. - Audit : en cas d’absence ou d’erreur sur la table
payment_audit_log, le log audit est ignoré (try/catch) pour ne pas bloquercreateSetupIntentni le flux de paiement.
13. Checklist déploiement
- [ ]
.env:STRIPE_KEY,STRIPE_SECRET,STRIPE_WEBHOOK_SECRETcorrects. - [ ]
.env:CRON_SECRETdéfini (token long, ex.openssl rand -hex 32) pour/cron-run. - [ ]
php artisan migrate(dontpayment_audit_log,stripe_transactions, colonnes Stripe surusers). - [ ] Webhook Stripe configuré vers l’URL de production, avec
payment_intent.succeeded(etcheckout.session.completedsi flux legacy utilisé). - [ ] CRON : soit
php artisan schedule:run(Laravel Scheduler), soit curl sur/cron-run(recommandé en Docker) :
0 6 * * * curl -s "https://votre-domaine.fr/cron-run?token=VOTRE_CRON_SECRET". Aucundocker execrequis. - [ ] Vérifier que les utilisateurs peuvent renseigner ville et code postal dans Paramètres → Compte.
- [ ] Rebuild des assets :
npm run build(ousail npm run build) pourcheckout.jset les vues.
Document généré pour le projet Allotata – mode Stripe « paiement invisible ».