Paiements

Paiement par carte, virement Interac, saisie manuelle, remboursements et idempotence du webhook.

Un seul registre

Paiements et remboursements vivent dans la même table. Un paiement est une ligne positive, un remboursement une ligne négative.

Cette convention de signe est ce qui rend le montant encaissé calculable par une seule somme, et ce qui fait qu'un remboursement rouvre une facture sans code supplémentaire : soustraire du montant payé fait repasser le statut de paid à partial par le chemin même qui l'avait mis à paid.

Toute écriture sur une transaction recalcule la facture, dans la même transaction de base de données : création, modification et suppression. C'est le correctif d'un défaut de l'ancien module, où seule la création synchronisait la facture.

La page publique

Trois adresses, toutes ouvertes par le seul jeton :

AdresseContenu
/lrvo/billing/<jeton>La page de paiement.
/lrvo/billing/<jeton>/pdfLa facture, affichée dans une visionneuse.
/lrvo/billing/<jeton>/recuLe reçu, une fois la facture soldée.

Chacune porte son titre d'onglet, Facture FAC-2026-0001 ou Reçu FAC-2026-0001, suivi du nom du site. Un jeton inconnu retombe sur le libellé seul : la page répond 404 de toute façon, et l'onglet n'a pas à révéler si le jeton existe. Les trois adresses sont en noindex : le jeton est la seule protection de la facture, une adresse indexée serait une facture publique.

Cela suppose que la route app/lrvo/[...path]/page.js du projet réexporte generateMetadata en plus du composant. npx lrvo-doctor --fix remonte une route restée à l'ancienne forme.

La page de paiement prend le cadre commun des pages publiques du core, en en-tête confortable : une facture est un document qu'on ouvre, pas un outil qu'on manœuvre. Voir Pages publiques.

Elle s'ouvre donc sur le logo de l'émetteur, le titre, « Facture à payer » ou « Facture payée », puis le montant et l'échéance sur une ligne, puis, s'il y a lieu, un bandeau : paiement reçu, paiement interrompu, facture annulée, facture en retard. Suivent le montant de la facture, les liens vers les documents, le tableau des lignes avec sa ventilation de taxes, et les notes. La colonne de droite porte le moyen de paiement, ou la confirmation verte une fois la facture soldée.

Sur mobile, le tableau des lignes devient une pile de cartes. Le thème clair ou sombre suit le réglage du visiteur, avec un bouton flottant pour en changer.

Cette page n'est pas le document formel. L'adresse complète du client, le code client et les coordonnées de l'émetteur ne s'y trouvent pas : ils sont sur le PDF, que le client télécharge depuis la visionneuse. La page ne porte que ce qu'il faut pour comprendre ce qui est dû et le régler ; le nom de l'émetteur, le numéro de la facture et les numéros d'inscription à la TPS et à la TVQ figurent en pied de page.

La visionneuse affiche le PDF dans le navigateur au lieu de forcer un téléchargement, et le bouton de sa barre d'outils propose le fichier sous son vrai nom, facture-FAC-2026-0001-2026-03-12.pdf. Le document occupe toute la page : aucun en-tête ni bouton du module ne vient doubler la barre d'outils du navigateur, ni lui voler de la hauteur. Le document est rendu à chaque ouverture, jamais archivé : une facture modifiée ne laisse pas traîner une ancienne version.

Paiement par carte

Le bouton n'apparaît sur la page publique que si STRIPE_SECRET_KEY et STRIPE_PUBLISHABLE_KEY sont toutes deux présentes, et si le solde est positif.

Le parcours :

  1. Le client ouvre /lrvo/billing/<jeton> et clique sur le bouton de paiement.
  2. Le module résout le jeton côté serveur et crée une session Checkout pour le solde. Le navigateur n'envoie aucun montant et ne dit pas quelle facture il paie : il n'y a donc rien à falsifier.
  3. Stripe redirige vers sa page hébergée, puis revient sur la facture avec ?paiement=succes ou ?paiement=annule.
  4. Le webhook enregistre le paiement.

Le retour du navigateur n'est jamais une preuve de paiement. Le module s'en sert seulement pour afficher un bandeau. Seul le webhook écrit.

Le montant de la session est le solde, intérêts compris. Une facture en retard réglée par carte est donc soldée en une fois.

L'idempotence du webhook

Stripe livre chaque événement au moins une fois, et rejoue en cas d'échec. Sans protection, un rejeu créditerait la facture deux fois. C'était le cas de l'ancien module.

Deux gardes indépendantes :

La grille d'événements. Avant tout traitement, l'identifiant de l'événement est inséré dans lrvo_billing_stripe_event avec ON CONFLICT DO NOTHING. Aucune ligne retournée signifie que l'événement a déjà été vu : le module rend la main sans rien toucher, et le core accuse réception.

Les index uniques sur les identifiants Stripe. La table des transactions porte un index unique partiel sur stripe_session_id et un autre sur stripe_refund_id. Même un rejeu manuel avec un identifiant d'événement neuf ne peut pas insérer deux fois la même session.

En cas d'échec transitoire, la ligne de grille est supprimée avant que l'erreur remonte au core, qui répond alors 500. Sans cette suppression, le réessai de Stripe serait avalé comme un doublon. Un échec métier non rejouable, comme une facture introuvable, ferme l'événement et rend la main normalement, ce qui vaut accusé de réception : redemander trois jours durant un traitement qui ne peut pas aboutir n'aide personne.

Remboursements

Depuis la facture ou la liste des transactions, sur un paiement Stripe. Le montant par défaut est ce qui reste remboursable, et un remboursement partiel est accepté.

L'enchaînement : appel à Stripe, puis écriture immédiate de la transaction négative avec l'identifiant du remboursement. Le webhook charge.refunded qui suit ne fait rien, absorbé par l'index unique.

Chaque chemin est correct seul. Un remboursement lancé depuis le tableau de bord Stripe, sans passer par l'admin, est donc enregistré lui aussi, à l'arrivée du webhook.

Le solde remonte, le statut redescend de paid à partial, la date de paiement s'efface, et le client reçoit un avis.

Virement Interac

Une question et une réponse de sécurité sont générées à la première facture de chaque client, puis réutilisées. Elles apparaissent sur la page de paiement de toutes ses factures, avec l'adresse du bénéficiaire.

Les coordonnées sont regroupées dans un accordéon « Faire un virement Interac », dans la colonne de droite. Il est déplié d'office quand le virement est le seul moyen de payer, c'est-à-dire quand le paiement par carte n'est pas configuré, et replié quand le bouton de paiement par carte est présent. Le virement reste alors une solution de rechange offerte, sans concurrencer l'action principale. Le bloc n'apparaît pas du tout sur une facture soldée, puisque les coordonnées ne sont envoyées au navigateur que si le solde est positif.

La réponse ne peut pas être hachée, puisqu'elle doit être montrée au payeur. Elle est chiffrée en base quand LRVO_ENCRYPTION_KEY est configurée, en clair sinon, et le démarrage le signale.

Le virement reçu se saisit à la main dans l'admin, en mode « Virement Interac ». Rien n'est automatique : aucune institution ne notifie l'arrivée d'un virement.

Le bouton « Régénérer », dans la fiche du client, produit un nouveau couple. Un virement que le client aurait déjà préparé avec l'ancienne réponse ne passera plus.

Saisie manuelle

Comptant, chèque, virement bancaire, ou tout autre mode. Le formulaire est prérempli avec le solde, mais un montant inférieur est accepté et produit un paiement partiel.

Un paiement manuel se modifie et se supprime, ce qui recalcule la facture. Une transaction Stripe ne se modifie ni ne se supprime : le registre doit refléter ce que le processeur a fait, et le seul moyen d'annuler un encaissement Stripe est un remboursement.

Reçus

Un reçu PDF est disponible pour chaque paiement, dans l'admin comme sur la page publique une fois la facture soldée. Il reprend la ventilation des taxes, que le client utilise pour ses propres crédits de taxe sur les intrants.

Le courriel de reçu part automatiquement après un paiement par carte. Il porte le PDF en pièce jointe seulement quand le paiement solde la facture. Voir Courriels.

Notes

  • Les montants traversent la frontière Stripe sans conversion : le module et l'API Stripe comptent tous deux en cents entiers.
  • Un remboursement supérieur à ce qui reste remboursable est refusé avant l'appel à Stripe.
  • Les clés de test et de production ne se mélangent pas. Une session créée avec une clé de test n'est pas lisible avec une clé de production.

Sur cette page