caprel / laravel-billing
Quota, overrun and billing plumbing shared by the CAP-REL SaaS webservices: three allowances per metric, overrun billed per pack or unit at the monthly reading, acts pushed to smartmakersaasbilling
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- dedoc/scramble: ^0.13
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- livewire/livewire: ^3.6
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.5
Suggests
- livewire/livewire: The order page of the order tunnel (Caprel\Billing\Tunnel\Livewire\Order)
Provides
None
Conflicts
None
Replaces
None
README
Ce qu'un webservice SaaS de la maison doit savoir faire de son quota, de ce
qui le dépasse, et de ce qu'il en facture. Pendant de
caprel/laravel-ops
pour le déploiement.
Il porte la mécanique, jamais la règle de prix. Les prix vivent dans les fiches produit du Dolibarr central, les quotas et les marges sur le contrat : le paquet ne fait que les lire et en tirer une décision.
Le modèle qu'il met en oeuvre est décrit ci-dessous : le paquet se lit seul, sans la documentation interne qui a servi à le trancher.
Le modèle, en trois nombres
Par métrique et par compte, trois nombres lus sur le contrat Dolibarr, qui les tient de la fiche de la formule et peut les changer client par client. Les deux marges s'expriment en dépassement autorisé, jamais en plafond absolu.
quota:
documents:
kind: flow # consommé sur la période
included: 1000 # inclus dans le forfait
soft: 500 # dépassement servi et facturé
hard: 550 # au-delà, refusé
overage_pack: P-DOLIPDF-PACK-200
pack_size: 200
api_keys:
kind: stock # détenu : les clés actives
included: 5 # soft et hard à zéro : plafonné net
Pour un quota de 1000 avec ces marges :
| Consommé | Issue | Ce que le service fait |
|---|---|---|
| jusqu'à 1000 | INCLUDED | sert |
| 1001 à 1500 | SERVE_AND_BILL | sert, facture, signale une fois |
| 1501 à 1550 | SERVE_AND_WARN | sert, facture, alerte fortement |
| au-delà | REFUSED | refuse |
Trois propriétés de ce choix, et chacune a une raison :
0 / 0est le défaut d'une métrique sans référence de dépassement, et il est sûr. Un compte s'arrête à son forfait : il ne peut ni accumuler une dette, ni être vidé si ses identifiants fuient. Une formule qui facture son dépassement porte ses propres marges par défaut sur sa fiche ;- la marge dure n'est pas commerciale. La souple dit jusqu'où on fait crédit, la dure borne ce qu'un compte volé consomme en une période. Un client de dix ans mérite une souple large, pas une dure absente ;
- les deux ne se touchent pas. L'écart entre elles est un tampon : on ne coupe pas net au franchissement du crédit accordé, ce qui interromprait un traitement par lot en cours. On alerte d'abord.
Le dépassement se facture par une référence de taille N
Jamais par tranches. Une seule référence de dépassement par métrique et par
contrat, de taille N : pack_size: 200 est un pack, arrondi au pack
supérieur, pack_size: 1 est l'unité.
Ce que cette règle achète : le prix reste dans la fiche produit, la quantité facturée est un entier, c'est linéaire en quantité donc tirable, et il n'y a aucun optimiseur à écrire pour choisir entre trois packs de 200 et un de 500.
Le paquet ne calcule jamais un prix. Une formule dont le prix de dépassement diffère désigne sa propre fiche ; un client au tarif négocié le reçoit du niveau de prix de son tiers, sur la ligne de contrat.
Le dépassement se facture sur le contrat, d'après le relevé que le service pousse chaque jour (voir "Le relevé quotidien"). Ce qui a été servi au-delà de la marge dure n'est pas facturé : c'est le délai d'un refus, pas un crédit.
Ce qu'une formule déclare
Une formule (Plan) déclare toutes ses métriques dans $quotas, chacune
par un MetricQuota : genre, inclus, marges par défaut, suffixe de la
référence de dépassement, taille. $quota reste l'inclus de la métrique que
mesure le catalogue, et doit dire la même chose que sa déclaration.
new Plan('std', 'ABO-STD', 'Abonnement standard', 'Abonnement mensuel.', 50, 1, quotas: [
'documents' => new MetricQuota(50, soft: 500, hard: 1000, overageSuffix: 'DOC-SUP-STD'),
'api_keys' => new MetricQuota(1, soft: 9, hard: 19, overageSuffix: 'CLE-SUP', kind: MetricKind::Stock),
]);
- L'inclus est toujours écrit, et peut valoir zéro : un abonnement
d'accès dont toute la consommation se facture au relevé. Zéro exige une
référence de dépassement et une marge dure, sinon la formule ne vendrait
rien.
$quotaà zéro n'est accepté que si une déclaration le porte. - "Illimité" n'est pas une valeur. Une formule vendue sans limite écrit son plafond anti-abus comme inclus, sans référence de dépassement. Un négatif est refusé.
- Une métrique de stock (clés d'API, proxys, sièges) se refuse à la création de la pièce de trop, jamais à l'usage de celles détenues, et se facture sur ce qui a été actif à un moment de la période close.
$plan->options($metric, $prefix) écrit le texte options_json de la fiche
de la formule, que le module recopie sur chaque contrat qu'elle ouvre, et
$plan->allowance() ce que le tunnel écrit dans quotajson à l'ouverture.
Le texte commence par period_months, tiré de $durationMonths : la durée
de la période de quota, que le relevé quotidien lit sur le contrat (un
contrat mensuel prolongé douze fois a les mêmes dates qu'un annuel).
Utilisation
use Caprel\Billing\Quota\Allowance;
use Caprel\Billing\Quota\QuotaPolicy;
$allowance = Allowance::fromArray($contract['quota']['documents']);
$decision = (new QuotaPolicy)->decide($allowance, $consumedThisPeriod);
if (! $decision->isAllowed()) {
abort(402, 'Quota dépassé.');
}
if ($decision->outcome->isBillable()) {
// $decision->packsToBill() x $decision->overagePack()
}
QuotaPolicy est pur : des nombres entrent, une décision sort. Pas
d'Eloquent, pas de HTTP, pas d'horloge, donc la règle se teste sans base de
données et se lit sans contexte.
Les quotas lus sur le contrat
QuotaGuard demande les trois nombres à l'AllowanceProvider que le service
lie. Un service qui vend par contrat (voir "L'abonnement adossé au contrat")
peut les faire lire sur le contrat : ContractAllowances enveloppe son
fournisseur et répond, pour un compte et une métrique, l'entrée
quota.<métrique> des options de la ligne de contrat qui le sert, lue dans
le miroir local, jamais chez Dolibarr.
BILLING_CONTRACTS_ENABLED=true
BILLING_CONTRACTS_ALLOWANCES_FROM_CONTRACT=true
- La ligne qui répond est ouverte, commencée, et dans sa date de fin plus la marge, comme une ligne qui sert. Plusieurs lignes : celle qui a commencé le plus tard, la dernière décision commerciale.
- Les nombres se lisent comme
Allowance::fromArray():kind(flowpar défaut, oustock),included,softetharden dépassement autorisé, zéro par défaut,overage_packetpack_size(un par défaut). Unincludedabsent n'est pas zéro : le barème du service répond. Des marges sansoverage_packsont ramenées à zéro et journalisées : sans référence, rien ne facture le dépassement, le quota est un plafond net. - Le barème du service reste le plancher. Contrats désactivés, aucune ligne qui sert, pas d'options, pas d'entrée pour la métrique, une entrée illisible (journalisée) : le fournisseur du service répond.
- L'identifiant que la garde passe est la clé du compte. Il est traduit
en identité de contrat par le même
AccountDirectoryque le miroir : un annuaire qui implémenteIdentifiesAccountsvoit le compte chargé depuisbilling.tunnel.account_model(le modèle d'auth par défaut).
Sans la variable, rien ne change. Un service qui préfère câbler à la main enveloppe son propre fournisseur :
// AppServiceProvider::register()
$this->app->extend(AllowanceProvider::class, fn ($provider, $app) => $app->make(
\Caprel\Billing\ContractLines\ContractAllowances::class,
['inner' => $provider],
));
Les quotas par défaut d'une formule se déclarent sur sa fiche produit, d'où le module les recopie sur chaque contrat qu'elle ouvre :
new ProductDefinition(
suffix: $plan->suffix,
label: $plan->label,
description: $plan->description,
unit: 'MO',
options: $plan->options($this->metric(), $prefix),
);
Chaque référence de dépassement est aussi une fiche du catalogue (unité P),
et une ligne du contrat, à quantité nulle, que le relevé met à jour. Le
catalogue les ajoute à ses fiches sans les écrire à la main :
public function products(): array
{
return [
...array_map(fn (Plan $plan) => new ProductDefinition(/* la formule */), $this->plans()),
...OverageSheets::of($this->plans(), $this->metric()),
];
}
Le libellé, la description et le prix proposé de la fiche se déclarent sur la
métrique (overageLabel, overageDescription, overagePriceHt de
MetricQuota) ; sans eux, un texte tiré de la métrique et de la taille du
pack. Deux formules qui partagent une référence doivent la décrire de la même
façon, sinon OverageSheets refuse.
billing:catalogue-template écrit ce texte dans la colonne options_json de
la fiche, et l'écran d'import du module le pose dans le champ supplémentaire
options_json de la fiche qu'il crée (module smartmakersaasbilling récent : un
texte illisible y est écarté et signalé). Une fiche déjà existante n'est jamais
modifiée par l'import : ses options se corrigent à la main.
Ce texte tient sur une seule ligne, en YAML en ligne avec accolades :
{period_months: 1, quota: {Sign: {kind: flow, included: 0, soft: 160, hard: 200, overage_pack: P-UPTOSIGN-SIGN-SUP-BASE, pack_size: 1}}}
Pas de retour à la ligne, que l'écran d'import changerait en \n littéral,
ni de guillemet double, que Dolibarr retire d'un champ saisi. Le module lit
aussi le JSON et le YAML par blocs : une fiche écrite à la main sous l'une de
ces formes reste lisible.
Le relevé quotidien
Le service pousse chaque jour à smartmakersaasbilling le relevé de toutes ses
lignes de contrat ouvertes (capdoc:SAAS_BILLING.md#8.3). Il ne se fait plus
lire : la capacité OBAPI Usage & Metering a été retirée. Il lie un
UsageMeter, la seule pièce qu'il écrit :
// Ce que le compte a utilisé entre deux jours inclus, heure de Paris : un
// flux consommé sur ces jours, un stock actif à un moment de ces jours.
public function measure(int|string $accountId, string $metric, MetricKind $kind,
CarbonImmutable $start, CarbonImmutable $end): int;
Le paquet part des lignes du miroir et construit, pour chaque ligne de
formule (LineReading), l'entrée du relevé :
- la période de quota de la ligne : sa durée est
period_monthsdes options du contrat (mensuelle si absente), sa fin la date de fin de la ligne, et une première période commencée en cours compte depuis son début ; - par métrique déclarée par la formule : ce qui a été consommé depuis le début de la période (un flux) ou détenu depuis le 1er du mois (un stock), l'inclus, la référence de dépassement et le facturable, au-delà de l'inclus, plafonné à la marge dure, en packs ou à l'unité ;
- le quota lu est celui de la ligne relevée, jamais celui de la ligne qui
sert le compte aujourd'hui ni le barème du service. Une métrique sans quota
écrit sur la ligne n'est pas envoyée, et c'est journalisé ; une ligne sans
aucun quota part avec
metrics: []; - chaque acte va à la ligne qui sert son compte le jour où il a lieu,
choisie comme la garde de quota (commencée le plus tard, date de fin plus la
marge, plus grande
line_idà égalité). Un stock va à la ligne du dernier jour compté ; - seule la ligne de la formule est relevée : le module ne montre jamais les lignes de dépassement au service. Une ligne dont le compte est introuvable ne l'est pas non plus (journalisé ; Dolibarr la signale alors comme absente).
Un service qui a des revendeurs lie aussi
Caprel\Billing\Contracts\ResellerGroups : les rattachements datés d'un
compte, les comptes d'un groupe, le nom d'un compte. La ligne du revendeur compte alors tout son
groupe, chaque acte selon le rattachement de son jour, et porte detail : la
consommation de chaque compte, triée par identité, dont la somme est
consumed (capdoc:SAAS_BILLING.md#15.2).
Le même contrat sert le groupe : un client de revendeur est servi tant que la
ligne de son revendeur l'est (ContractMirror::payingLinesOf()), un impayé du
revendeur coupe donc tout le groupe ; ses quotas se lisent sur ce contrat, et
la garde ne le refuse jamais pour quota, puisque tout ce qui est servi est
facturé au revendeur. Changer un compte de groupe reste une opération du
service, datée : le paquet ne fait que lire les rattachements.
Le revendeur lit l'historique de consommation de ses clients sur douze mois
glissants (capdoc:SAAS_BILLING.md#15.4) :
// routes/api.php : la clé d'API du revendeur ; ResellerGroups::resellerFor()
// dit qui est revendeur, un client reçoit 404
Route::middleware('auth:sanctum')->group(fn () => Routes::resellerHistory());
GET revendeur/consommation rend les lignes en JSON, GET
revendeur/consommation.csv les mêmes en CSV (point-virgule, UTF-8, en-tête,
entiers sans séparateur de milliers) : mois, compte, nom, métrique,
consommation, statut (definitif ou provisoire), référence de la facture du
mois. Un mois clos se lit dans le relevé définitif accepté et son detail,
jamais dans un nouveau comptage ; le mois en cours, ou un mois clos dont le
définitif n'est pas encore tenu, dans le dernier relevé envoyé. Chaque client
n'apparaît que dans les mois où il était rattaché ; ?account= filtre un
client, et un compte hors du groupe répond 404. Le chemin se règle par
billing.resellers.history_path.
L'envoi
billing:push-usage pousse le relevé par POST releves, une fois par jour,
après minuit à Paris : il est arrêté à la veille, journée entière, en heure de
Paris quel que soit le fuseau de l'application. La commande n'existe que si
le service vend par contrat (BILLING_CONTRACTS_ENABLED), lie un
PlanCatalogue et un UsageMeter.
Schedule::command('billing:push-usage')->dailyAt('01:30')->timezone('Europe/Paris');
- 500 lignes au plus par appel, les pages d'une même date numérotées
pagesurpages. Le canal est celui des commandes : OAuth, coupe-circuit, nouvel essai sur connexion, 429 et 5xx, jamais sur 422. - La réponse se lit ligne par ligne :
accepted,final_recorded,refusedavec son motif (final_differs,unknown_line,invalid). Une ligne refusée est journalisée et n'arrête pas les autres ; elle repart à l'envoi suivant, recalculée, sauf unfinal_differs. - Le relevé daté du dernier jour d'un mois est définitif. Il attend que chaque métrique de la formule ait un quota écrit sur la ligne : en attendant, il part provisoire, et le définitif suit une fois le quota posé. Un mois clos dont le module ne tient pas le définitif (envoi manqué, page en échec) repart dans un appel daté de son dernier jour.
- Une ligne résiliée reste dans le relevé jusqu'à la fin du mois de son dernier jour, puis jusqu'à ce que le module tienne son définitif.
- La première fraction est facturée (
capdoc:SAAS_BILLING.md#8.3). Les jours qu'une commande paie au jour avant un contrat différé partent une fois, sur la ligne de ce contrat, comme définitif du mois où ils finissent :perioddu premier jour payé à la veille du contrat, appel daté de ce dernier jour, envoyé dès le relevé de ce jour (le 1er du contrat). Les quotas sont ceux de la fraction payée (ReadsPaidPeriods), qui l'ont servie pendant ces jours ; la consommation est celle du compte de la ligne. Une fraction remboursée n'est pas relevée ; une fraction que le module ne tient pas repart comme un mois clos. Cherchée pendantDailyPush::FRACTION_WINDOW_DAYSjours (92) après le début du contrat. - Ce qui est parti se garde dans
billing_usage_readings: le dernier relevé provisoire de chaque mois et le définitif, avec la réponse du module, et chaque appel dansbilling_usage_pushes. Un envoi rejoué le même jour ne change rien.
La commande sort en erreur quand une page n'a pas pu partir, et nomme les lignes refusées, celles sans quota et les définitifs en attente.
Le canal vers le Dolibarr central
Caprel\Billing\Channel\BillingClient appelle les neuf verbes du module
smartmakersaasbilling. Il porte les règles que quatre services ont
apprises une à une, et chacune est tenue par un test qui la nomme :
- le jeton est mis en cache, jamais journalisé, et la politique de rejeu
s'applique aussi à son obtention - le portail limite
/oauth/tokenet répond 429, ce qui arrive pour de vrai quand un déploiement finit parcache:clear; - un 429 n'est pas un refus d'identifiants : compté comme tel, il ouvre le coupe-circuit et ferme la boutique un quart d'heure sur un incident qui se résout en secondes ;
- le coupe-circuit s'ouvre après trois refus consécutifs ; un appel qui passe remet le compteur à zéro ;
- on ne rejoue que ce qui est rejouable : connexion, 429, 5xx. Jamais un 422, qui signale une charge utile fausse ;
- la référence d'une commande n'est jamais encodée dans le chemin : un
brouillon s'appelle
(PROV12), et%28PROV12%29donne un 404 sur la présentation au paiement de toutes les commandes. Elle est vérifiée, pas échappée ; - aucun montant ne part d'ici. Une ligne porte une référence produit et une quantité entière.
- toute URL tracée passe par
LogSanitizer::safeUrl(), qui retire la requête et l'identité : un jeton, une signature ou la clé d'un lien de paiement ne finit jamais dans un journal.
La table d'actes, et pourquoi deux colonnes de référence
billing_acts est le vrai travail d'un chantier de facturation, bien avant
l'appel HTTP : la facturation existe même quand le canal est absent,
donc un acte s'enregistre et attend plutôt que de disparaître.
Deux colonnes de référence, parce qu'une seule en a porté deux sens et a produit trois pannes d'un coup, dont un accès qui ne se rouvrait jamais après un règlement pourtant encaissé :
| Colonne | Qui la fabrique | À quoi elle sert |
|---|---|---|
reference | nous, avant l'appel | l'idempotence, et le nom que la poussée renvoie |
draft_ref | Dolibarr, à la création | adresser la validation, et rien d'autre |
central_order_ref | Dolibarr, à la validation | tous les verbes suivants |
Le registre des mouvements
Dû par tout service appliquant le modèle du quota, et pas seulement par ceux qui se pensent vendeurs de crédits : un forfait avec quota a un solde, alimenté par le forfait du mois, les packs achetés et les gestes commerciaux, diminué par chaque acte. Un compteur seul ne dit pas d'où il vient.
Le solde se lit depuis les mouvements, et il n'y a pas de colonne de
solde à côté : ce qu'un grep sur les incréments devait garantir, l'absence
d'un second nombre le garantit par construction.
Recevoir un règlement
Caprel\Billing\Webhook\SettlementController vérifie la signature
HMAC-SHA256 du corps brut sous le secret de webhook de la fiche
SaasClient - pas le secret OAuth, et sans repli sur lui : les deux n'ont
ni le même cycle de vie ni la même façon d'être récupérés, et un repli
masque la mauvaise configuration au lieu de la nommer.
Le corps est un déclencheur, jamais une source : l'état est relu auprès du module avant toute écriture. Les quatre événements sont traités, dont les trois que Dolibarr décide seul - sans eux un service continue de servir un abonnement dont l'argent est reparti.
Le service écoute ActStateChanged et décide ce qu'un règlement veut dire
chez lui.
Les événements de ligne de contrat arrivent à la même adresse ; ils ne sont traités que par un service qui a activé l'abonnement adossé au contrat (voir plus bas), et ignorés sinon.
La recherche de comptes du Dolibarr central
L'écran "Nouveau contrat SaaS" du module pose le contrat d'un client depuis
Dolibarr. L'identité du compte ne s'y tape jamais : la personne la choisit
dans une liste que le service lui donne. Routes::accountDirectory()
déclare les deux lectures, et le verbe qui donne le compte d'une vente de la
boutique, que le service monte dans son groupe d'API, hors de toute
authentification, comme le webhook :
// routes/api.php
\Caprel\Billing\Tunnel\Routes::accountDirectory();
| Route | Rend |
|---|---|
GET billing/directory/accounts?q=<texte> | les comptes dont l'e-mail, le nom, le prénom ou la raison sociale contient le texte (3 caractères au moins, 20 comptes au plus, comptes supprimés exclus) |
GET billing/directory/accounts/{identité} | le compte que l'identité désigne, 404 sinon ; relu par le module juste avant d'écrire le contrat |
POST billing/directory/accounts | le compte d'une vente de la boutique, trouvé par son adresse ou ouvert pour elle (ci-dessous) |
Chaque compte porte identity (ce que le contrat portera, nommé par
AccountIdentities), email, name, company, created et serving :
la ligne ouverte du miroir qui le sert déjà (contract_ref, line_id,
product_ref, date_end), null sinon. Le module refuse de poser un
second contrat sur un compte servi.
Un compte porte aussi last_activity quand le service sait la dire : la
dernière chose que le compte a faite, pour que la personne qui pose le
contrat sache qu'il est vivant.
"last_activity": {"at": "2026-10-14T18:14:00+02:00", "label": "A signé un document"}
at est en ISO 8601 avec son fuseau ; label dit en quelques mots la nature
de l'acte, coupé à 120 caractères, et jamais son contenu (ni nom de
document, ni destinataire, ni montant) : ce que l'annuaire renvoie s'affiche
à toute personne qui lit les contrats. Le champ est omis quand le service ne
sait pas dire. Le service le fournit en liant
Caprel\Billing\Contracts\DescribesAccountActivity, appelée une fois par
compte décrit (20 au plus par recherche) : une seule lecture indexée.
// AppServiceProvider::register()
$this->app->bind(\Caprel\Billing\Contracts\DescribesAccountActivity::class, MonActivite::class);
// lastActivity(Model $account): ?array{at: CarbonInterface, label: string}
Sans liaison, ou si l'implémentation lève une exception (journalisée), le champ est omis et la réponse reste servie.
Les colonnes cherchées sont celles que la table des comptes possède parmi
email, name, firstname et company.
Le compte d'une vente de la boutique. La boutique One Page Basket vend
une formule par une commande Dolibarr ordinaire ; au retour du paiement, le
module demande le compte sur lequel poser le contrat
(capdoc:SAAS_BILLING.md#13.7) :
{"email": "achat@client.test", "mode": "find_or_create", "order_ref": "CO2610-0042", "locale": "fr",
"company": {"name": "Client SARL", "country": "FR", "siren": "...", "address": "...", "zip": "...", "town": "..."}}
find_or_create, pour l'adresse avec laquelle l'acheteur s'est connecté à la boutique, qui l'a vérifiée : le compte qui la porte est le sien, sinon il est ouvert (AccountDirectory::open()) ;create_only, pour une adresse saisie dans le panier (un contrat de plus, pour une filiale) : le compte est toujours ouvert, jamais trouvé. Une adresse qui porte déjà un compte est refusée en 409account_exists, sans rien créer ni envoyer : une adresse saisie ne fait jamais porter un contrat au compte d'un autre.order_refy est obligatoire.
Un compte ouvert reçoit un seul mail, AccountOpenedForPurchase, dont le
lien fixe le premier mot de passe et confirme ainsi l'adresse. D'ici là, le
compte porte un mot de passe que personne ne connaît : il reste bloqué. Le
mail n'est jamais renvoyé ; un client qui l'a perdu passe par "Mot de passe
oublié", qui prouve la boîte de la même façon.
La réponse porte account (mêmes champs que la recherche, serving
compris : le module sait sans second appel si le compte est déjà servi) et
created, vrai seulement si cet appel a ouvert le compte. Le module ne s'en
sert pas pour décider : un appel rejoué après une réponse perdue retrouve le
compte, par l'adresse ou, en create_only, par order_ref gardée avec lui
(table billing_directory_openings), et répond created: false, sans second
mail. Refus : 409 account_deleted pour un compte supprimé (jamais ranimé),
409 order_conflict pour une même order_ref envoyée avec une autre
adresse, 422 invalid avec message pour une requête illisible.
La signature. Un GET n'a pas de corps : le module signe ce que la
requête demande, avec le secret de webhook de la fiche SaasClient. Le
POST signe son corps brut, octet pour octet.
X-Dolibarr-Timestamp: <secondes unix>
X-Dolibarr-Signature: hex(hmac_sha256("<horodatage>\n<verbe>\n<argument>", secret))
Le verbe vaut search (argument : le texte cherché), show (argument :
l'identité) ou open (argument : le corps JSON). Ni le chemin ni l'hôte ne sont signés : un proxy ou un préfixe
d'API les changent en route. Le verbe empêche de rejouer la signature d'une
recherche sur l'autre lecture. Un horodatage écarté de plus de
billing.directory.signature_tolerance secondes (300 par défaut) est
refusé. Les lectures ne font que lire, et le POST rejoué retrouve le compte
que le premier a ouvert : une requête rejouée dans la fenêtre n'apprend rien
que sa première réponse n'ait dit.
Refus : 401 avec missing_signature, stale_timestamp ou
invalid_signature, journalisé avec sa raison ; 503 not_configured sans
secret ; 422 query_too_short. Chaque appel est journalisé avec le texte
cherché, l'identité ou l'adresse, et l'IP.
Les trois commandes d'un raccordement
php artisan billing:catalogue-template --output=catalogue.json
php artisan billing:sync-catalogue
php artisan billing:doctor
Le service déclare ce qu'il vend en liant CatalogueDefinition. Les
références produit se dérivent du préfixe configuré et ne sont jamais
écrites en dur : c'est le point qui tient toute la chaîne, puisque le
relevé quotidien lit la même source. Écrites à deux endroits, elles divergent
au premier renommage et rien ne le signale - la mesure ne correspond alors
à aucune ligne de contrat, la ligne est facturée à sa quantité figée, sans
une erreur nulle part.
billing:doctor s'arrête à la première étape qui échoue et nomme la cause
plutôt que le code : un 401 sur un jeton pourtant délivré est un client
absent de SMARTAUTH_API_AUDIENCE, un 403 est une fiche SaasClient qui ne
correspond à aucun client OAuth, un 500 au corps vide est une erreur fatale
dans le module et la seule chose utile est de dire où regarder. Il
n'affiche jamais le jeton.
Le barème contrôlé, il lit le relevé quotidien dans les tables locales : en
erreur, une formule qui facture un dépassement sans UsageMeter lié ou sans
être vendue au mois calendaire, ou aucun
relevé accepté depuis trois jours (le module le signale aussi) ; en
avertissement, la date du dernier relevé accepté et les appels en échec
depuis, les lignes ouvertes sans quota écrit pour une métrique de leur formule
(leur définitif attend), les contrats sans period_months (lus mensuels) et
les lignes refusées, avec leur motif.
Le tunnel de commande
De la page de tarifs à une clé d'API qui fonctionne : une formule, quelques
champs, le paiement sur le Dolibarr central, la clé affichée une seule fois.
Aucun compte à créer avant : il s'ouvre avec la commande et sert dès le
règlement. La règle du parc et ses raisons sont dans
capdoc:TUNNEL_COMMANDE.md.
Le tunnel n'existe que pour un service qui lie un catalogue :
// AppServiceProvider::register()
$this->app->bind(\Caprel\Billing\Tunnel\PlanCatalogue::class, MonCatalogue::class);
// routes/web.php, dans le groupe "web"
\Caprel\Billing\Tunnel\Routes::web(); // /commande et /commande/{uuid} signé
// routes/api.php, dans le groupe versionné
\Caprel\Billing\Tunnel\Routes::api(); // POST checkout, GET checkout/{id}
Ce qui change d'un service à l'autre ne se configure pas, il se lie. Quatre contrats, chacun avec un défaut qui convient au socle commun :
| Contrat | Défaut | Ce qu'il décide |
|---|---|---|
PeriodPolicy | AnniversaryPeriod | comment le temps se vend : N mois à partir du jour du paiement, ou CalendarPeriod (le 1er du mois, première fraction au prorata) |
SubscriptionStore | SocleSubscriptions | ce qu'un paiement ouvre : une ligne de la table subscriptions par acte réglé |
AccountDirectory | SocleAccounts | ce qu'est un compte, son rôle, ce qu'il détient avant que la purge le supprime |
PaidAccess | SubscriptionPaidAccess | qui est servi : une période en cours aujourd'hui, pas une simple ligne d'abonnement |
Ce que le paquet fournit ensuite, sans rien écrire :
- la page de commande Livewire, aussi pour un client connecté qui renouvelle ;
- l'API de commande préparée par un module Dolibarr (secret de réclamation, clé remise une fois au module) ;
- l'ouverture de la période au règlement, sa fermeture au remboursement ;
- le mail "votre compte est actif" avec le lien qui fixe le premier mot de passe, et l'adresse vérifiée par ce lien ;
- le middleware
billing.paid, qui répond 402 avec l'adresse de commande ; billing:remind-renewalsetbilling:purge-unpaid-orders, à planifier.
La page est écrite en classes Tailwind ordinaires : la feuille publique du service doit les compiler.
@source '../../vendor/caprel/laravel-billing/resources/views';
Les textes sont en anglais dans les sources, traduits par le paquet en français, allemand, espagnol, italien, néerlandais et portugais du Brésil ; un fichier de langue du service remplace une ligne.
L'abonnement adossé au contrat
Une formule débitée chaque mois vit sur un contrat du Dolibarr central : ses
lignes s'ouvrent au premier paiement, se prolongent à chaque facture et se
ferment sur un impayé constaté après le délai de grâce. Le service tient un
miroir local de ces lignes et sert à partir de lui, sans jamais appeler
Dolibarr par requête : une panne du Dolibarr central ne coupe personne. Le
modèle est dans capdoc:SAAS_BILLING.md#13.
Désactivé par défaut. Un service qui ne l'active pas ne voit rien changer :
même magasin d'abonnements, notifications de ligne de contrat ignorées comme
avant, aucune commande de plus. La table billing_contract_lines est créée
par la migration du paquet dans tous les cas, et reste vide.
Activer
BILLING_CONTRACTS_ENABLED=truedans l'environnement.- Sur la fiche SaasClient, cocher "Vend des abonnements" : sans ce réglage,
le module refuse en 422 la commande qui porte un bloc
contract. - Marquer les formules débitées chaque mois dans le
PlanCatalogue:new Plan(..., recurring: true). Une formule sans ce drapeau se vend comme avant, payée une fois. Elle se vend enAnniversaryPeriodcomme enCalendarPeriod(voir "Le mois calendaire"), sauf une formule qui facture son dépassement : elle se vend enCalendarPeriod, son contrat commence un 1er pour que chaque période finisse avec un mois et tombe dans un relevé définitif. Le paquet refuse de la commander autrement (le module refuserait en 422contract_start_not_first), etbilling:doctorle signale. - Si l'identité d'un compte n'est pas sa clé primaire (plusieurs sortes de
comptes, une organisation plutôt qu'un utilisateur), faire implémenter
Caprel\Billing\Tunnel\Accounts\IdentifiesAccountspar sonAccountDirectory:contractIdentity()rend une chaîne stable, unique, de 128 caractères au plus, etaccountForIdentity()retrouve le compte qu'elle désigne (le relevé quotidien part des lignes de contrat et doit mesurer le compte que chacune sert). - Planifier la relecture chaque jour :
Schedule::command('billing:sync-contracts')->dailyAt('03:30'); - Poser le bandeau dans la mise en page des pages connectées :
<x-billing-overdue-banner />, et ajouter la vue au@sourcecomme la page de commande.
Ce que fait le paquet
| Pièce | Ce qu'elle fait |
|---|---|
PlanCheckout | pour une formule recurring, ajoute à POST commandes le bloc contract : account (l'identité du compte), date_start (premier jour de la période vendue), duration_months, product_refs (la fiche de la formule). Une formule ponctuelle n'envoie rien. Une première fraction vendue au jour (CalendarPeriod) envoie un contrat différé au 1er qui la suit |
SettlementController | reçoit les sept événements contract.line.* à la même adresse, sous le même secret et la même signature X-Dolibarr-Signature que payment.accepted |
contract.line.invoiced | la référence de la facture qui a facturé le dépassement d'un mois clos, sur la ligne de la formule (line_id, month, invoice_ref) : gardée dans billing_contract_invoices sans relire le contrat, puisque la ligne ne change pas ; la lecture du contrat porte les mêmes références (invoices, douze mois glissants) et rattrape une notification manquée |
ContractMirror | le corps déclenche, l'état se relit : chaque notification relit le contrat par GET contrats/{ref} et écrit toutes ses lignes telles que lues. Si la lecture échoue, le corps tient lieu d'état provisoire (colonne source à pushed), c'est journalisé, et la relecture du lendemain corrige. Une notification rejouée ne change rien, une notification plus ancienne que l'état connu n'est pas appliquée. Chaque ligne garde aussi les options du contrat (colonne options, ses quotas) |
ContractLineSubscriptions | enveloppe le SubscriptionStore lié (celui du service compris) : ce qu'un acte réglé ouvre reste écrit par lui, et un compte est aussi servi tant qu'il a une ligne ouverte dont la date de fin, plus la marge, n'est pas passée. Un compte dont la ligne court au-delà du jour de rappel ne reçoit pas le mail "renouvelez" |
ContractAllowances | sur option, lit les quotas d'un compte sur les options de sa ligne de contrat avant le barème du service (voir "Les quotas lus sur le contrat") |
billing:sync-contracts | relit chaque contrat du miroir ; avec des références (billing:sync-contracts CT2610-0004), seulement celles-là. Sort en erreur et nomme les contrats illisibles |
ContractLineChanged | événement envoyé après chaque notification appliquée, pour un mail à l'impayé ou à la coupure |
La date de fin est un filet, pas une règle de paiement : elle protège
contre notre propre panne, une facturation qui ne tourne plus. La marge
(end_margin_days, trois jours par défaut) évite de couper les clients avant
qu'on ait vu l'incident. Une ligne ouverte sans date de fin est servie.
Le mois calendaire
Une formule débitée vendue au mois calendaire (CalendarPeriod) ne fait pas
porter au contrat la ligne au jour : elle serait débitée chaque mois.
- Commandée le 1er, la période est entière : contrat sur la fiche de la
formule,
date_startau jour de la commande, comme enAnniversaryPeriod. - Commandée en cours de mois, la commande ne porte que la ligne au jour
(
-JOUR), qui paie les jours jusqu'à la bascule. Le bloccontractest différé :date_startau lendemain de la fin de la fraction (un 1er),product_refssur la fiche mensuelle de la formule, que la commande ne vend pas. Le module l'accepte parce que le contrat commence strictement après le jour de la commande, et construit la ligne depuis la fiche. Chaque métrique de la formule reçoit sa part de l'inclus pour les jours payés, arrondie au-dessus (ProratesQuotas::share()), pas seulement la métrique mesurée ; les marges restent entières.
Au paiement, le module ouvre la ligne avec sa date de début à venir, et la
notification contract.line.opened porte cette date. Ce que le miroir en
fait :
- la ligne est écrite ouverte, avec sa date de début future, qu'elle vienne de la relecture ou, si la relecture échoue, du corps de la notification ;
- la date de début n'est pas lue pour servir : avant elle, les jours sont payés par l'acte, qui ouvre sa propre période ; la ligne n'accorde donc rien qui n'ait été payé, et à partir du 1er elle sert seule ;
- pour ordonner les notifications, une date d'événement future compte pour le jour de réception : une fermeture reçue avant le 1er n'est pas prise pour une notification en retard ;
- la ligne court au-delà de la fin de la fraction : le client ne reçoit pas le mail "renouvelez" ;
ContractAllowancesne lit ses quotas qu'à partir de sa date de début : avant, ce sont les quotas de la fraction payée, écrits au paiement par leSubscriptionStore(ReadsPaidPeriods, colonnequotajsondu socle), marges et référence de dépassement comprises : la fraction est servie et facturée comme un mois de la formule. Un magasin qui ne sait pas les lire, ou une métrique absente, laisse le barème du service répondre.
Changer de formule
Un changement de formule ne touche jamais le contrat en cours : il ouvre un
nouveau contrat au début de la période suivante, que la formule monte ou
descende, sans prorata ni quota coupé en deux (capdoc:SAAS_BILLING.md#8.4).
Le service porte la demande du client ; l'autre porte est une action de la
fiche contrat dans Dolibarr.
$change = app(\Caprel\Billing\ContractLines\PlanChange::class)->request($compte, $formule);
// null si le compte n'a pas de contrat en cours, sinon
// ['contract' => 'CT2611-0007', 'effective' => '2026-11-01']
app(\Caprel\Billing\ContractLines\PlanChange::class)->cancel($compte);
request()appellePOST contrats/{ref}/changementsur le contrat de la ligne de formule ouverte du compte : le module pose le nouveau contrat en brouillon, lié à l'ancien. Un second appel remplace le brouillon,cancel()(DELETE) le supprime tant qu'il n'a pas pris effet.- Le jour venu, l'ancien contrat se ferme et le nouveau s'ouvre : le service
le voit par
contract.line.closedetcontract.line.opened, et la garde de quota passe de l'un à l'autre ce jour-là. La dernière consommation de l'ancien part sur sa facture de clôture, à l'arrivée de son relevé définitif.
La facture impayée
Pendant le délai de grâce, la ligne reste ouverte et le client est servi. Le service choisit entre un bandeau et une pause :
$overdue = app(\Caprel\Billing\ContractLines\ContractMirror::class)->overdueFor($account);
// null, ou PaymentOverdue : invoiceRef, amountDue, firstFailureOn, closingOn,
// isClosed() quand la ligne a été fermée pour cette dette
- le bandeau
<x-billing-overdue-banner />(ou:account="$compte") : rien tant qu'il n'y a pas d'impayé, sinon la facture, le montant et la date de fermeture, ou l'accès suspendu une fois la ligne fermée ; - la pause :
BILLING_CONTRACTS_PAUSE_WHEN_OVERDUE=true, et la gardebilling.paidrépond 402 avecmeta.reason = payment_overdueet la facture dansmeta.payment_overdue.
La dette disparaît au règlement (contract.line.payment_settled). La garde
n'est montée que sur ce que le service vend : s'authentifier, lire son compte
et payer ne sont jamais coupés, ni pendant le délai de grâce, ni après la
fermeture.
Configuration
| Clé | Défaut | Rôle |
|---|---|---|
billing.contracts.enabled (BILLING_CONTRACTS_ENABLED) | false | miroir, magasin, bloc contract des formules recurring |
billing.contracts.end_margin_days (BILLING_CONTRACTS_END_MARGIN_DAYS) | 3 | jours servis après la date de fin d'une ligne ouverte |
billing.contracts.pause_when_overdue (BILLING_CONTRACTS_PAUSE_WHEN_OVERDUE) | false | 402 pendant le délai de grâce au lieu de servir |
billing.contracts.allowances_from_contract (BILLING_CONTRACTS_ALLOWANCES_FROM_CONTRACT) | false | quotas lus sur les options du contrat avant le barème du service |
Les options du contrat se relisent comme ses lignes. Un texte que le module ne
sait pas lire (options_error) ne les efface pas : les dernières connues
restent, et c'est journalisé. Un contrat dont les options ont été retirées
les perd au miroir.
Le bloc contracts est une clé de premier niveau : un config/billing.php
de service qui ne le déclare pas reçoit celui du paquet.
La vente d'unités
Un lot de crédits, une activation à l'unité, un achat qui désigne un objet du
service : des unités d'un solde, achetées maintenant, dépensées plus tard. Le
cahier des charges est dans capdoc:SAAS_BILLING.md, section 14.
La vente d'unités n'existe que pour un service qui lie un catalogue d'offres :
// AppServiceProvider::register()
$this->app->bind(\Caprel\Billing\Units\OfferCatalogue::class, \Caprel\Billing\Units\ConfigOfferCatalogue::class);
$this->app->bind(\Caprel\Billing\Units\ConsumptionTarget::class, MaCible::class); // si un achat désigne un objet
$this->app->bind(\Caprel\Billing\Contracts\SellerContacts::class, MesVendeurs::class); // qui prévenir
ConfigOfferCatalogue lit le bloc billing.units (métrique, TVA affichée,
offres). Ce que le paquet fournit ensuite :
| Pièce | Ce qu'elle fait |
|---|---|
OfferCheckout::open() | écrit la vente et son acte, reprend la vente ouverte de la même offre pour le même objet, tamponne les conditions acceptées, présente le lien de paiement |
UnitOrder | la vente (offre, unités, objet, conditions, avoir) ; son état est celui de l'acte ; aucune clé étrangère vers le compte |
Units | crédit, dépense sous verrou du compte (jamais sous zéro), reprise, total net par référence |
UnitSettlement | au règlement : crédit idempotent sur le total net, puis dépense sur l'objet ; événement UnitsPurchased pour le mail du service |
UnitReversal | annulation qui relit d'abord le règlement, avoir qui reprend les unités avant l'appel, reprise d'un règlement défait |
BuyerRecovery | le bouton "J'ai payé, vérifier", limité en fréquence |
billing:reconcile-acts | chaque heure, règle ce que la notification n'a pas apporté et nomme au vendeur les commandes en souffrance |
billing:purge-unpaid-orders | annule à trente jours ce que personne n'a payé, sans jamais supprimer une vente |
billing:expire-units | reprend les unités passées leur validité (billing.units.validity_months), les plus anciennes consommées d'abord |
Une offre crédite le solde billing.units.metric, ou le sien quand elle porte
une clé metric : un même catalogue vend ainsi des packs de signatures et des
packs de scellements, chacun sur son solde.
Les unités expirent quand billing.units.validity_months est posé :
billing:expire-units, à planifier chaque jour, reprend par un mouvement
d'expiration ce qui reste des unités créditées avant la date limite. Les plus
anciennes sont consommées d'abord : ce qui expire est la part des crédits
antérieurs à la date limite que la consommation n'a pas atteinte. Seul ce qui
a été acheté ou donné expire (pack, code, geste), jamais le droit qu'une
formule ouvre à chaque période ; un solde vendu en unités ne se partage donc
pas avec une formule.
// routes/console.php du service
Schedule::command('billing:expire-units')->dailyAt('02:30');
Le plafond de volume se règle dans billing.guard, et les alertes au vendeur
passent une fois par fenêtre par le cache nommé dans
billing.alerts.cache_store.
Éprouver un service contre le vrai module
Caprel\Billing\Testing\ModuleDouble démarre un vrai Dolibarr avec le module
smartmakersaasbilling (son routeur de test, sur une base en RAM), y pose le
client OAuth, la fiche du client autorisé et le catalogue du service, puis
l'arrête en remettant le dépôt du module dans l'état où il l'a trouvé. La
recette et ses pièges sont dans capdoc:TESTING_DOLIBARR_DOUBLE.md.
Un service n'écrit que ses réglages et, si ses suites en ont besoin, son jeu de données :
// tests/Support/BillingDouble.php
final class BillingDouble extends \Caprel\Billing\Testing\ModuleDouble
{
protected static function settings(): \Caprel\Billing\Testing\DoubleSettings
{
return new \Caprel\Billing\Testing\DoubleSettings(
service: 'monservice',
reservedPort: 9330, // capdoc:PORTS.md
portVariable: 'MONSERVICE_TEST_BILLING_PORT',
oauthClientId: 'monservicerecette',
oauthClientSecret: 'secret-oauth',
webhookSecret: 'secret-webhook', // jamais égal au secret OAuth
clientRef: 'monservice',
productRefPrefix: 'P-MONSERVICE-',
applicationRoot: dirname(__DIR__, 2),
priceGridVariable: 'MONSERVICE_BILLING_DOUBLE_PRICE_LEVELS',
tokenStore: 'state', // le cache où le client lit son jeton
);
}
protected static function seed(): array
{
return DemoData::seed();
}
}
BillingDouble::ready() rend le double démarré, provisionné et peuplé ;
configureChannel() y branche le canal de l'application. Les suites se
sautent sur une machine où les dépôts smartmakersaasbilling, smartauth et
stancer manquent (isAvailable()). pushesFor($lineId, $event) relit les
notifications que le module a réellement envoyées pour une ligne, corps et
signature compris, pour les rejouer dans le contrôleur de l'application.
Un service qui vend par contrat passe sellsSubscriptions: true : le
provisionnement coche alors "Vend des abonnements" sur la fiche du client
autorisé, sans quoi le module refuse en 422 toute commande qui porte un bloc
contract. catalogueCommand dit ce qui lance billing:catalogue-template
dans applicationRoot (php artisan par défaut).
Le paquet a son propre double (tests/Support/PackageDouble.php, fenêtre de
ports 9420-9439, LARAVEL_BILLING_TEST_BILLING_PORT) : il éprouve contre le
vrai module le contrat, le relevé quotidien, la référence de la facture du
mois rendue au service et le changement de formule, ce qu'aucun service
n'éprouve encore. Le gabarit est écrit par vendor/bin/testbench (testbench.yaml),
la suite est à part et se lance à la demande :
make billing-double # phpunit -c phpunit-billing-double.xml
Documentation d'API
Le paquet expose ces routes ; le service les monte où il veut (voir plus haut) et décide ce que sa documentation publie.
| Route | Publiée | Pourquoi |
|---|---|---|
POST checkout | oui | appelée par les modules Dolibarr clients |
GET checkout/{checkout} | oui | idem, avec l'en-tête X-Checkout-Secret |
SettlementController (webhook) | non | appelée par le Dolibarr central seul, adresse fixée sur la fiche SaasClient |
AccountDirectoryController (recherche de comptes) | non | appelée par le Dolibarr central seul, signée comme le webhook, rend des e-mails de clients |
Les routes de commande ne valident pas leur entrée : un champ qui ne rentre
pas dans sa limite est ignoré, jamais refusé en 422, pour qu'un Dolibarr à
la fiche société incomplète obtienne quand même un lien. Scramble n'a donc
rien à déduire ; ce qu'il publie vient des attributs BodyParameter,
HeaderParameter, QueryParameter et Response posés sur
CheckoutController, qui ne valident rien. ScrambleDocumentationTest
vérifie que la spécification générée porte les huit champs et l'en-tête.
Les annotations Scribe (@bodyParam, @response) restent tant qu'un
service consommateur est encore sur Scribe.
Le webhook reste hors de la documentation : ce n'est pas une route d'un client, et publier sa forme n'aide que celui qui voudrait la contrefaire. Le paquet ne le masque pas lui-même, c'est la liste des routes masquées du service qui le porte, par exemple :
// AppServiceProvider::boot()
\Dedoc\Scramble\Scramble::configure()->routes(
fn (\Illuminate\Routing\Route $route) => str_starts_with($route->uri, 'api/')
&& ! in_array($route->getControllerClass(), [
\Caprel\Billing\Webhook\SettlementController::class,
\Caprel\Billing\ContractLines\AccountDirectoryController::class,
], true),
);
Développement
make install
make ci # pint, phpstan niveau 8 sans baseline, phpunit
Licence
AGPL-3.0-or-later, voir LICENSE.md.
Les services qui installent ce paquet sont publiés sous la même licence, et l'un d'eux ne doit pas cesser d'être redéployable par un tiers parce qu'une pièce manque. Un clone qui installe ce paquet sans configurer de canal de facturation applique quand même ses quotas : il ne facture simplement personne, ce qui est le comportement attendu d'une installation autonome.
Publier ne révèle rien de commercial, parce que rien de commercial n'est ici. Les prix, les barèmes, les références produit et les marges accordées à chaque client vivent dans le Dolibarr central, pas dans ce code.