Comment dissocier l'historique de facturation des statuts d'abonnements, et aligner les transactions des passerelles de paiement avec les tableaux de support client.
Dans les applications SaaS sur abonnement, la complexité de facturation s'accroît lorsque se croisent factures clients, plans d'abonnements dynamiques et interfaces de gestion interne du support.
Un problème classique est de ne pas dissocier l'état d'abonnement de la liste brute des factures : l'utilisateur voit son forfait actuel uniquement sous forme d'une suite de factures payées, tandis que l'équipe support fait face à des écarts de prix affichés sur son interface en raison de notifications asynchrones de passerelles de paiement (Webhooks) perdues. Voyons comment concevoir une synchronisation fiable.
Une base de données de facturation robuste requiert une séparation logique des tables :
1. Table Subscriptions : Administre l'état actuel de l'abonnement (active, canceled, past_due), le niveau de forfait et la date de renouvellement.
2. Table Invoices : Enregistre l'historique des transactions financières, les reçus et les états de paiement.
3. Tableaux d'Administration : Interrogent une API de synthèse plutôt que de sommer des écritures comptables brutes et partielles.
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Structure de la table d'abonnements
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('stripe_subscription_id')->unique();
$table->string('stripe_price_id');
$table->string('status'); class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// active, past_due, trialing, canceled
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
});
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Structure de la table des factures historisées
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id')->constrained();
$table->string('stripe_invoice_id')->unique();
$table->integer('amount_paid'); class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Stocké en centimes (ex: 2900 pour 29.00€)
$table->string('currency');
$table->string('status'); class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// paid, open, uncollectible
$table->timestamps();
});Pour résoudre les écarts de prix dans l'interface de support, configurez un récepteur de Webhook mettant à jour l'état de la base de données dès qu'un paiement est validé par la passerelle externe.
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// app/Http/Controllers/BillingWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Subscription;
use App\Models\Invoice;
class BillingWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->all();
$eventType = $payload['type'];
switch ($eventType) {
case 'invoice.payment_succeeded':
$this->processPaymentSucceeded($payload['data']['object']);
break;
case 'customer.subscription.updated':
$this->processSubscriptionUpdated($payload['data']['object']);
break;
}
return response('Webhook Handled', 200);
}
protected function processPaymentSucceeded(array $invoiceData)
{
$subscription = Subscription::where('stripe_subscription_id', $invoiceData['subscription'])->first();
if ($subscription) {
Invoice::updateOrCreate(
['stripe_invoice_id' => $invoiceData['id']],
[
'subscription_id' => $subscription->id,
'amount_paid' => $invoiceData['amount_paid'], class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Stocké en centimes
'currency' => $invoiceData['currency'],
'status' => 'paid',
]
);
}
}
}Lors de la restitution des montants financiers aux administrateurs du support, stockez systématiquement les valeurs en centimes et utilisez des accesseurs de conversion pour éliminer les erreurs d'arrondis sur les nombres à virgule flottante.
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// app/Models/Invoice.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Accesseur : Conversion des centimes en dollars/euros pour l'affichage
public function getFormattedAmountAttribute()
{
return '$' . number_format($this->amount_paid / 100, 2);
}
}En structurant vos abonnements séparément de l'historique des paiements et en s'appuyant sur des webhooks unitaires, vous assurez un moteur de facturation sans incohérences.