Search by

caprel / laravel-billing

Eric-CAP-REL

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

Package info

inligit.fr/cap-rel/laravel/laravel-billing

pkg:composer/caprel/laravel-billing

Statistics

Installs: 44

Dependents: 0

Suggesters: 0

v0.4.6 2026-10-16 21:00 UTC

This package is auto-updated.

Last update: 2026-10-17 13:10:02 UTC


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éIssueCe que le service fait
jusqu'à 1000INCLUDEDsert
1001 à 1500SERVE_AND_BILLsert, facture, signale une fois
1501 à 1550SERVE_AND_WARNsert, facture, alerte fortement
au-delàREFUSEDrefuse

Trois propriétés de ce choix, et chacune a une raison :

  • 0 / 0 est 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 (flow par défaut, ou stock), included, soft et hard en dépassement autorisé, zéro par défaut, overage_pack et pack_size (un par défaut). Un included absent n'est pas zéro : le barème du service répond. Des marges sans overage_pack sont 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 AccountDirectory que le miroir : un annuaire qui implémente IdentifiesAccounts voit le compte chargé depuis billing.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_months des 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 page sur pages. 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, refused avec 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 un final_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 : period du 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 pendant DailyPush::FRACTION_WINDOW_DAYS jours (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 dans billing_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/token et répond 429, ce qui arrive pour de vrai quand un déploiement finit par cache: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%29 donne 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é :

ColonneQui la fabriqueÀ quoi elle sert
referencenous, avant l'appell'idempotence, et le nom que la poussée renvoie
draft_refDolibarr, à la créationadresser la validation, et rien d'autre
central_order_refDolibarr, à la validationtous 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();
RouteRend
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/accountsle 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 409 account_exists, sans rien créer ni envoyer : une adresse saisie ne fait jamais porter un contrat au compte d'un autre. order_ref y 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 :

ContratDéfautCe qu'il décide
PeriodPolicyAnniversaryPeriodcomment le temps se vend : N mois à partir du jour du paiement, ou CalendarPeriod (le 1er du mois, première fraction au prorata)
SubscriptionStoreSocleSubscriptionsce qu'un paiement ouvre : une ligne de la table subscriptions par acte réglé
AccountDirectorySocleAccountsce qu'est un compte, son rôle, ce qu'il détient avant que la purge le supprime
PaidAccessSubscriptionPaidAccessqui 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-renewals et billing: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

  1. BILLING_CONTRACTS_ENABLED=true dans l'environnement.
  2. Sur la fiche SaasClient, cocher "Vend des abonnements" : sans ce réglage, le module refuse en 422 la commande qui porte un bloc contract.
  3. 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 en AnniversaryPeriod comme en CalendarPeriod (voir "Le mois calendaire"), sauf une formule qui facture son dépassement : elle se vend en CalendarPeriod, 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 422 contract_start_not_first), et billing:doctor le signale.
  4. 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\IdentifiesAccounts par son AccountDirectory : contractIdentity() rend une chaîne stable, unique, de 128 caractères au plus, et accountForIdentity() retrouve le compte qu'elle désigne (le relevé quotidien part des lignes de contrat et doit mesurer le compte que chacune sert).
  5. Planifier la relecture chaque jour : Schedule::command('billing:sync-contracts')->dailyAt('03:30');
  6. Poser le bandeau dans la mise en page des pages connectées : <x-billing-overdue-banner />, et ajouter la vue au @source comme la page de commande.

Ce que fait le paquet

PièceCe qu'elle fait
PlanCheckoutpour 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
SettlementControllerreç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.invoicedla 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
ContractMirrorle 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)
ContractLineSubscriptionsenveloppe 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"
ContractAllowancessur 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-contractsrelit 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_start au jour de la commande, comme en AnniversaryPeriod.
  • Commandée en cours de mois, la commande ne porte que la ligne au jour (-JOUR), qui paie les jours jusqu'à la bascule. Le bloc contract est différé : date_start au lendemain de la fin de la fraction (un 1er), product_refs sur 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" ;
  • ContractAllowances ne 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 le SubscriptionStore (ReadsPaidPeriods, colonne quotajson du 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() appelle POST contrats/{ref}/changement sur 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.closed et contract.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 garde billing.paid répond 402 avec meta.reason = payment_overdue et la facture dans meta.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éfautRôle
billing.contracts.enabled (BILLING_CONTRACTS_ENABLED)falsemiroir, magasin, bloc contract des formules recurring
billing.contracts.end_margin_days (BILLING_CONTRACTS_END_MARGIN_DAYS)3jours servis après la date de fin d'une ligne ouverte
billing.contracts.pause_when_overdue (BILLING_CONTRACTS_PAUSE_WHEN_OVERDUE)false402 pendant le délai de grâce au lieu de servir
billing.contracts.allowances_from_contract (BILLING_CONTRACTS_ALLOWANCES_FROM_CONTRACT)falsequotas 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èceCe 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
UnitOrderla vente (offre, unités, objet, conditions, avoir) ; son état est celui de l'acte ; aucune clé étrangère vers le compte
Unitscrédit, dépense sous verrou du compte (jamais sous zéro), reprise, total net par référence
UnitSettlementau règlement : crédit idempotent sur le total net, puis dépense sur l'objet ; événement UnitsPurchased pour le mail du service
UnitReversalannulation qui relit d'abord le règlement, avoir qui reprend les unités avant l'appel, reprise d'un règlement défait
BuyerRecoveryle bouton "J'ai payé, vérifier", limité en fréquence
billing:reconcile-actschaque heure, règle ce que la notification n'a pas apporté et nomme au vendeur les commandes en souffrance
billing:purge-unpaid-ordersannule à trente jours ce que personne n'a payé, sans jamais supprimer une vente
billing:expire-unitsreprend 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.

RoutePubliéePourquoi
POST checkoutouiappelée par les modules Dolibarr clients
GET checkout/{checkout}ouiidem, avec l'en-tête X-Checkout-Secret
SettlementController (webhook)nonappelée par le Dolibarr central seul, adresse fixée sur la fiche SaasClient
AccountDirectoryController (recherche de comptes)nonappelé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.