Automatisation de la Facturation
Maîtrisez les systèmes de facturation automatisés incluant la facturation récurrente, la génération de factures, la gestion du recouvrement, la proratisation et le calcul fiscal.
Quand utiliser cette compétence
- Implémenter la facturation des abonnements SaaS
- Automatiser la génération et la livraison de factures
- Gérer la récupération des paiements échoués (recouvrement)
- Calculer les frais proratisés pour les changements de plan
- Gérer la taxe de vente, la TVA et la GST
- Traiter la facturation basée sur l'utilisation
- Gérer les cycles de facturation et les renouvellements
Concepts fondamentaux
1. Cycles de facturation
Intervalles courants :
- Mensuel (le plus courant pour SaaS)
- Annuel (tarif réduit à long terme)
- Trimestriel
- Hebdomadaire
- Personnalisé (basé sur l'utilisation, par siège)
2. États de l'abonnement
trial → active → past_due → canceled
→ paused → resumed
3. Gestion du recouvrement
Processus automatisé pour récupérer les paiements échoués via :
- Calendriers de nouvelles tentatives
- Notifications aux clients
- Périodes de grâce
- Restrictions de compte
4. Proratisation
Ajustement des frais lors de :
- Amélioration/rétrogradation en milieu de cycle
- Ajout/suppression de sièges
- Changement de fréquence de facturation
Démarrage rapide
from billing import BillingEngine, Subscription
# Initialiser le moteur de facturation
billing = BillingEngine()
# Créer un abonnement
subscription = billing.create_subscription(
customer_id="cus_123",
plan_id="plan_pro_monthly",
billing_cycle_anchor=datetime.now(),
trial_days=14
)
# Traiter le cycle de facturation
billing.process_billing_cycle(subscription.id)
Gestion du cycle de vie des abonnements
from datetime import datetime, timedelta
from enum import Enum
class SubscriptionStatus(Enum):
TRIAL = "trial"
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
PAUSED = "paused"
class Subscription:
def __init__(self, customer_id, plan, billing_cycle_day=None):
self.id = generate_id()
self.customer_id = customer_id
self.plan = plan
self.status = SubscriptionStatus.TRIAL
self.current_period_start = datetime.now()
self.current_period_end = self.current_period_start + timedelta(days=plan.trial_days or 30)
self.billing_cycle_day = billing_cycle_day or self.current_period_start.day
self.trial_end = datetime.now() + timedelta(days=plan.trial_days) if plan.trial_days else None
def start_trial(self, trial_days):
"""Démarrer la période d'essai."""
self.status = SubscriptionStatus.TRIAL
self.trial_end = datetime.now() + timedelta(days=trial_days)
self.current_period_end = self.trial_end
def activate(self):
"""Activer l'abonnement après la période d'essai ou immédiatement."""
self.status = SubscriptionStatus.ACTIVE
self.current_period_start = datetime.now()
self.current_period_end = self.calculate_next_billing_date()
def mark_past_due(self):
"""Marquer l'abonnement comme en retard après un paiement échoué."""
self.status = SubscriptionStatus.PAST_DUE
# Déclencher le flux de recouvrement
def cancel(self, at_period_end=True):
"""Annuler l'abonnement."""
if at_period_end:
self.cancel_at_period_end = True
# Sera annulé à la fin de la période actuelle
else:
self.status = SubscriptionStatus.CANCELED
self.canceled_at = datetime.now()
def calculate_next_billing_date(self):
"""Calculer la prochaine date de facturation en fonction de l'intervalle."""
if self.plan.interval == 'month':
return self.current_period_start + timedelta(days=30)
elif self.plan.interval == 'year':
return self.current_period_start + timedelta(days=365)
elif self.plan.interval == 'week':
return self.current_period_start + timedelta(days=7)
Traitement du cycle de facturation
class BillingEngine:
def process_billing_cycle(self, subscription_id):
"""Traiter la facturation pour un abonnement."""
subscription = self.get_subscription(subscription_id)
# Vérifier si la facturation est due
if datetime.now() < subscription.current_period_end:
return
# Générer une facture
invoice = self.generate_invoice(subscription)
# Tenter le paiement
payment_result = self.charge_customer(
subscription.customer_id,
invoice.total
)
if payment_result.success:
# Paiement réussi
invoice.mark_paid()
subscription.advance_billing_period()
self.send_invoice(invoice)
else:
# Paiement échoué
subscription.mark_past_due()
self.start_dunning_process(subscription, invoice)
def generate_invoice(self, subscription):
"""Générer une facture pour la période de facturation."""
invoice = Invoice(
customer_id=subscription.customer_id,
subscription_id=subscription.id,
period_start=subscription.current_period_start,
period_end=subscription.current_period_end
)
# Ajouter un élément de ligne d'abonnement
invoice.add_line_item(
description=subscription.plan.name,
amount=subscription.plan.amount,
quantity=subscription.quantity or 1
)
# Ajouter les frais basés sur l'utilisation si applicable
if subscription.has_usage_billing:
usage_charges = self.calculate_usage_charges(subscription)
invoice.add_line_item(
description="Usage charges",
amount=usage_charges
)
# Calculer la taxe
tax = self.calculate_tax(invoice.subtotal, subscription.customer)
invoice.tax = tax
invoice.finalize()
return invoice
def charge_customer(self, customer_id, amount):
"""Facturer le client en utilisant la méthode de paiement enregistrée."""
customer = self.get_customer(customer_id)
try:
# Facturer via le processeur de paiement
charge = stripe.Charge.create(
customer=customer.stripe_id,
amount=int(amount * 100), # Convertir en centimes
currency='usd'
)
return PaymentResult(success=True, transaction_id=charge.id)
except stripe.error.CardError as e:
return PaymentResult(success=False, error=str(e))
Gestion du recouvrement
class DunningManager:
"""Gérer la récupération des paiements échoués."""
def __init__(self):
self.retry_schedule = [
{'days': 3, 'email_template': 'payment_failed_first'},
{'days': 7, 'email_template': 'payment_failed_reminder'},
{'days': 14, 'email_template': 'payment_failed_final'}
]
def start_dunning_process(self, subscription, invoice):
"""Démarrer le processus de recouvrement pour un paiement échoué."""
dunning_attempt = DunningAttempt(
subscription_id=subscription.id,
invoice_id=invoice.id,
attempt_number=1,
next_retry=datetime.now() + timedelta(days=3)
)
# Envoyer une notification d'échec initial
self.send_dunning_email(subscription, 'payment_failed_first')
# Planifier les nouvelles tentatives
self.schedule_retries(dunning_attempt)
def retry_payment(self, dunning_attempt):
"""Réessayer le paiement échoué."""
subscription = self.get_subscription(dunning_attempt.subscription_id)
invoice = self.get_invoice(dunning_attempt.invoice_id)
# Tenter le paiement à nouveau
result = self.charge_customer(subscription.customer_id, invoice.total)
if result.success:
# Paiement réussi
invoice.mark_paid()
subscription.status = SubscriptionStatus.ACTIVE
self.send_dunning_email(subscription, 'payment_recovered')
dunning_attempt.mark_resolved()
else:
# Toujours en échec
dunning_attempt.attempt_number += 1
if dunning_attempt.attempt_number < len(self.retry_schedule):
# Planifier la prochaine tentative
next_retry_config = self.retry_schedule[dunning_attempt.attempt_number]
dunning_attempt.next_retry = datetime.now() + timedelta(days=next_retry_config['days'])
self.send_dunning_email(subscription, next_retry_config['email_template'])
else:
# Tentatives épuisées, annuler l'abonnement
subscription.cancel(at_period_end=False)
self.send_dunning_email(subscription, 'subscription_canceled')
def send_dunning_email(self, subscription, template):
"""Envoyer une notification de recouvrement au client."""
customer = self.get_customer(subscription.customer_id)
email_content = self.render_template(template, {
'customer_name': customer.name,
'amount_due': subscription.plan.amount,
'update_payment_url': f"https://app.example.com/billing"
})
send_email(
to=customer.email,
subject=email_content['subject'],
body=email_content['body']
)
Proratisation
class ProrationCalculator:
"""Calculer les frais proratisés pour les changements de plan."""
@staticmethod
def calculate_proration(old_plan, new_plan, period_start, period_end, change_date):
"""Calculer la proratisation pour un changement de plan."""
# Jours dans la période actuelle
total_days = (period_end - period_start).days
# Jours utilisés sur l'ancien plan
days_used = (change_date - period_start).days
# Jours restants sur le nouveau plan
days_remaining = (period_end - change_date).days
# Calculer les montants proratisés
unused_amount = (old_plan.amount / total_days) * days_remaining
new_plan_amount = (new_plan.amount / total_days) * days_remaining
# Frais/crédit net
proration = new_plan_amount - unused_amount
return {
'old_plan_credit': -unused_amount,
'new_plan_charge': new_plan_amount,
'net_proration': proration,
'days_used': days_used,
'days_remaining': days_remaining
}
@staticmethod
def calculate_seat_proration(current_seats, new_seats, price_per_seat, period_start, period_end, change_date):
"""Calculer la proratisation pour les changements de sièges."""
total_days = (period_end - period_start).days
days_remaining = (period_end - change_date).days
# Frais de sièges supplémentaires
additional_seats = new_seats - current_seats
prorated_amount = (additional_seats * price_per_seat / total_days) * days_remaining
return {
'additional_seats': additional_seats,
'prorated_charge': max(0, prorated_amount), # Aucun remboursement pour suppression de sièges en milieu de cycle
'effective_date': change_date
}
Calcul fiscal
class TaxCalculator:
"""Calculer la taxe de vente, la TVA, la GST."""
def __init__(self):
# Taux fiscaux par région
self.tax_rates = {
'US_CA': 0.0725, # Taxe de vente en Californie
'US_NY': 0.04, # Taxe de vente à New York
'GB': 0.20, # TVA au Royaume-Uni
'DE': 0.19, # TVA en Allemagne
'FR': 0.20, # TVA en France
'AU': 0.10, # GST en Australie
}
def calculate_tax(self, amount, customer):
"""Calculer la taxe applicable."""
# Déterminer la juridiction fiscale
jurisdiction = self.get_tax_jurisdiction(customer)
if not jurisdiction:
return 0
# Obtenir le taux fiscal
tax_rate = self.tax_rates.get(jurisdiction, 0)
# Calculer la taxe
tax = amount * tax_rate
return {
'tax_amount': tax,
'tax_rate': tax_rate,
'jurisdiction': jurisdiction,
'tax_type': self.get_tax_type(jurisdiction)
}
def get_tax_jurisdiction(self, customer):
"""Déterminer la juridiction fiscale en fonction de la localisation du client."""
if customer.country == 'US':
# États-Unis : Taxe basée sur l'état du client
return f"US_{customer.state}"
elif customer.country in ['GB', 'DE', 'FR']:
# UE : TVA
return customer.country
elif customer.country == 'AU':
# Australie : GST
return 'AU'
else:
return None
def get_tax_type(self, jurisdiction):
"""Obtenir le type de taxe pour la juridiction."""
if jurisdiction.startswith('US_'):
return 'Sales Tax'
elif jurisdiction in ['GB', 'DE', 'FR']:
return 'VAT'
elif jurisdiction == 'AU':
return 'GST'
return 'Tax'
def validate_vat_number(self, vat_number, country):
"""Valider un numéro de TVA européen."""
# Utiliser l'API VIES pour la validation
# Retourne True si valide, False sinon
pass
Génération de factures
class Invoice:
def __init__(self, customer_id, subscription_id=None):
self.id = generate_invoice_number()
self.customer_id = customer_id
self.subscription_id = subscription_id
self.status = 'draft'
self.line_items = []
self.subtotal = 0
self.tax = 0
self.total = 0
self.created_at = datetime.now()
def add_line_item(self, description, amount, quantity=1):
"""Ajouter un élément de ligne à la facture."""
line_item = {
'description': description,
'unit_amount': amount,
'quantity': quantity,
'total': amount * quantity
}
self.line_items.append(line_item)
self.subtotal += line_item['total']
def finalize(self):
"""Finaliser la facture et calculer le total."""
self.total = self.subtotal + self.tax
self.status = 'open'
self.finalized_at = datetime.now()
def mark_paid(self):
"""Marquer la facture comme payée."""
self.status = 'paid'
self.paid_at = datetime.now()
def to_pdf(self):
"""Générer une facture PDF."""
from reportlab.pdfgen import canvas
# Générer le PDF
# Inclure : informations de l'entreprise, informations du client, éléments de ligne, taxe, total
pass
def to_html(self):
"""Générer une facture HTML."""
template = """
<!DOCTYPE html>
<html>
<head><title>Invoice #{invoice_number}</title></head>
<body>
<h1>Invoice #{invoice_number}</h1>
<p>Date: {date}</p>
<h2>Bill To:</h2>
<p>{customer_name}<br>{customer_address}</p>
<table>
<tr><th>Description</th><th>Quantity</th><th>Amount</th></tr>
{line_items}
</table>
<p>Subtotal: ${subtotal}</p>
<p>Tax: ${tax}</p>
<h3>Total: ${total}</h3>
</body>
</html>
"""
return template.format(
invoice_number=self.id,
date=self.created_at.strftime('%Y-%m-%d'),
customer_name=self.customer.name,
customer_address=self.customer.address,
line_items=self.render_line_items(),
subtotal=self.subtotal,
tax=self.tax,
total=self.total
)
Facturation basée sur l'utilisation
class UsageBillingEngine:
"""Suivre et facturer l'utilisation."""
def track_usage(self, customer_id, metric, quantity):
"""Suivre un événement d'utilisation."""
UsageRecord.create(
customer_id=customer_id,
metric=metric,
quantity=quantity,
timestamp=datetime.now()
)
def calculate_usage_charges(self, subscription, period_start, period_end):
"""Calculer les frais d'utilisation dans la période de facturation."""
usage_records = UsageRecord.get_for_period(
subscription.customer_id,
period_start,
period_end
)
total_usage = sum(record.quantity for record in usage_records)
# Tarification échelonnée
if subscription.plan.pricing_model == 'tiered':
charge = self.calculate_tiered_pricing(total_usage, subscription.plan.tiers)
# Tarification à l'unité
elif subscription.plan.pricing_model == 'per_unit':
charge = total_usage * subscription.plan.unit_price
# Tarification en volume
elif subscription.plan.pricing_model == 'volume':
charge = self.calculate_volume_pricing(total_usage, subscription.plan.tiers)
return charge
def calculate_tiered_pricing(self, total_usage, tiers):
"""Calculer le coût en utilisant une tarification échelonnée."""
charge = 0
remaining = total_usage
for tier in sorted(tiers, key=lambda x: x['up_to']):
tier_usage = min(remaining, tier['up_to'] - tier['from'])
charge += tier_usage * tier['unit_price']
remaining -= tier_usage
if remaining <= 0:
break
return charge