Subscriptions
Vue d’ensemble
Section titled “Vue d’ensemble”L’abonnement est au niveau du Tenant (pas de l’utilisateur). Chaque tenant (cabinet/structure) souscrit un plan qui détermine ses quotas et features disponibles.
Stack technique :
- Laravel Cashier v15 pour l’intégration Stripe
- Paiement par SEPA debit uniquement
- Le modèle
Subscriptionest en base centrale (pas tenant) viaCentralConnection
Architecture
Section titled “Architecture”Fichiers clés
Section titled “Fichiers clés”| Fichier | Rôle |
|---|---|
src/Domain/Subscription/Enums/PlanKey.php | Enum des plans disponibles |
src/Domain/Subscription/Enums/FeatureKey.php | Enum de toutes les features |
src/Domain/Subscription/Enums/FeatureState.php | États possibles d’une feature |
src/Domain/Subscription/IPlan.php | Interface d’un plan |
src/Domain/Subscription/APlan.php | Classe abstraite d’un plan |
src/Domain/Subscription/Plans/*.php | Implémentation de chaque plan |
src/Domain/Subscription/Feature.php | Value object d’une feature |
src/Domain/Subscription/Models/Subscription.php | Modèle Eloquent (étend Cashier) |
src/Domain/Subscription/Concerns/TenantSubscription.php | Trait sur le Tenant pour la gestion billing |
src/Domain/Subscription/Services/SubscriptionPlanService.php | Résolution plan key → instance |
src/Domain/Subscription/Resources/PricingTable.php | Resource API pour la pricing page |
config/app.php → plans | Stripe Price IDs par plan/feature (prod vs staging) |
app/Modules/Subscriptions/Controllers/SubscriptionController.php | Controller principal |
webapp/lib/constants.ts | PRICING_PLANS_MAP et PRICING_PLANS_LANG_MAP |
Modèle de données Tenant
Section titled “Modèle de données Tenant”Le Tenant stocke les informations de souscription directement sur son modèle :
plan_commitment → PlanCommitment (monthly | yearly)plan_commitment_ends_at → datetimeplan_storage_included → int (Go inclus, -1 = illimité)plan_users_included → int (-1 = illimité)plan_sites_included → int (-1 = illimité)plan_storage_forced → int|null (override admin)plan_users_forced → int|null (override admin)plan_sites_forced → int|null (override admin)plan_features_base → array (features du plan)plan_features_enabled → array (addons activés)plan_features_forced → array|null (override admin)subscription_bypass → bool (bypass total)Les 3 plans actuels
Section titled “Les 3 plans actuels”| Plan | PlanKey | Prix/mois | Max users | Max sites | Frais plateforme |
|---|---|---|---|---|---|
| Standard | starter | 58,80 € | 1 | 1 | 10% |
| Indépendant | independent | 88,80 € | 1 | 2 | 0% |
| Cabinet | medical_office | 105,60 € | illimité | illimité | 0% |
Tous ont des frais d’installation de 200 € (actuellement offerts via installation_fee_offered: true).
Engagement
Section titled “Engagement”- Monthly : engagement d’1 mois
- Yearly : engagement d’1 an + coupon de réduction automatique (
yearly_billing_discount) - Impossible de downgrade de yearly vers monthly tant que
plan_commitment_ends_atest dans le futur
- 15 jours d’essai pour les nouveaux tenants (première souscription uniquement)
- Résiliation possible uniquement pendant le trial
Features
Section titled “Features”États d’une feature
Section titled “États d’une feature”| État | FeatureState | Description |
|---|---|---|
| Inclus | INCLUDED | Inclus dans le plan, automatiquement activé |
| Optionnel | OPTIONAL | Addon activable/désactivable par le tenant |
| Indisponible | UNAVAILABLE | Pas disponible pour ce plan |
Types de facturation
Section titled “Types de facturation”| Type | Description | Exemple |
|---|---|---|
| Fixed | Prix fixe par unité, quantité ajustée via updateQuantity | Sièges utilisateurs (10-91 €/siège) |
| Metered | Usage mesuré par tranches, reporté via reportMeterEvent | Stockage (25 Go/tranche), Sites |
| Gratuit | Inclus sans facturation Stripe | Doctolib, FSE, IA, paiements in-app |
Matrice features × plans
Section titled “Matrice features × plans”| Feature | FeatureKey | Starter | Independent | Medical Office |
|---|---|---|---|---|
| Base | base | 39 € (fixed) | 39 € (fixed) | 39 € (fixed) |
| Orthoptiste | user-orthoptist | 10 €/siège | 35 €/siège | 49 €/siège |
| Ophtalmologue | user-ophthalmologist | — | — | 91 €/siège |
| Secrétaire | user-secretary | — | — | 41 €/siège |
| Stockage | storage | 15 Go inclus | 25 Go inclus | 35 Go inclus |
| Sites | sites | — | — | 2 inclus, 39 €/sup |
| Doctolib | addons-doctolib | ✓ gratuit | ✓ gratuit | ✓ gratuit |
| FSE | addons-fse | ✓ gratuit | ✓ gratuit | ✓ gratuit |
| Paiements | in_app-payments | ✓ gratuit | ✓ gratuit | ✓ gratuit |
| IA | addons-ai-generation | ✓ gratuit | ✓ gratuit | ✓ gratuit |
| Desktop | desktop-app | — | ✓ gratuit | ✓ gratuit |
| Ordoclic | addons-ordoclic | — | 4,90 € (optional) | ✓ gratuit |
Feature gating
Section titled “Feature gating”tenant->isFeatureEnabled(FeatureKey) vérifie si une feature est accessible en combinant :
plan_features_base ∪ plan_features_enabled ∪ plan_features_forcedSi subscription_bypass = true, toutes les features sont activées (override admin).
Le middleware subscribed protège les routes nécessitant un abonnement actif.
Cycle de vie
Section titled “Cycle de vie”Subscribe (POST /billing/subscribe)
Section titled “Subscribe (POST /billing/subscribe)”- Vérifie qu’aucune sub active n’existe
- Crée une subscription Stripe via Cashier avec les prix fixed + metered + sièges
- Trial de 15 jours si première souscription
- Applique coupon promo ou réduction annuelle
- Facture les frais d’installation séparément (si pas offerts)
- Stocke quotas et features sur le tenant
Update plan (PUT /billing/subscription)
Section titled “Update plan (PUT /billing/subscription)”- Swap des prix Stripe avec prorations
- Vérifie la période d’engagement (yearly → monthly interdit si engagement en cours)
- Reporte les features optionnelles compatibles avec le nouveau plan
- Applique/retire le coupon annuel
- Met à jour quotas et features sur le tenant
Unsubscribe (DELETE /billing/unsubscribe)
Section titled “Unsubscribe (DELETE /billing/unsubscribe)”- Uniquement possible pendant la période d’essai
- Sinon →
InCommitmentPeriodException
Resume (POST /billing/subscription/resume)
Section titled “Resume (POST /billing/subscription/resume)”- Reprend une sub annulée mais en grace period
Add/Remove feature (POST/DELETE /billing/features)
Section titled “Add/Remove feature (POST/DELETE /billing/features)”- Ajoute/retire un addon optionnel sur la subscription Stripe
- Met à jour
plan_features_enabledsur le tenant
Sync des métriques
Section titled “Sync des métriques”Le trait TenantSubscription synchronise les quantités vers Stripe :
| Méthode | Ce qu’elle sync |
|---|---|
updateBillableUsers() | Nombre de sièges par rôle (orthoptiste, ophtalmo, secrétaire) |
updateBillableStorage() | Stockage utilisé via meter events |
updateBillableSites() | Nombre de sites actifs via meter events |
Webhooks Stripe
Section titled “Webhooks Stripe”Les webhooks sont routés via StripeEventListener → StripeWebhookService :
| Event Stripe | Handler | Action |
|---|---|---|
customer.subscription.created | CustomerSubscriptionCreated | Sync quotas/features sur le tenant |
customer.subscription.updated | CustomerSubscriptionUpdated | — |
customer.subscription.deleted | CustomerSubscriptionDeleted | — |
Les handlers sont dans src/Infrastructure/Stripe/Webhooks/CustomerSubscription/.
Guide : Ajouter une Feature
Section titled “Guide : Ajouter une Feature”1. Ajouter la clé dans l’enum
Section titled “1. Ajouter la clé dans l’enum”enum FeatureKey: string{ // ... features existantes case MA_NOUVELLE_FEATURE = 'ma-nouvelle-feature';}2. Créer le prix Stripe
Section titled “2. Créer le prix Stripe”Dans le dashboard Stripe, créer un Price pour la feature :
- Fixed : prix récurrent par unité (ex: siège)
- Metered : prix basé sur l’usage (Meter event)
Garder les Price IDs pour prod et staging.
3. Ajouter le Price ID dans la config
Section titled “3. Ajouter le Price ID dans la config”// config/app.php → plans → {plan_key} → features'ma-nouvelle-feature' => env('APP_ENV', 'production') === 'production' ? 'price_PROD_ID' : 'price_STAGING_ID',4. Déclarer la feature dans chaque plan concerné
Section titled “4. Déclarer la feature dans chaque plan concerné”// src/Domain/Subscription/Plans/{Plan}.php → featureConfig()
// Feature fixed incluse (ex: addon gratuit)FeatureKey::MA_NOUVELLE_FEATURE->value => Feature::create( FeatureKey::MA_NOUVELLE_FEATURE, FeatureState::INCLUDED),
// Feature fixed payanteFeatureKey::MA_NOUVELLE_FEATURE->value => Feature::create( FeatureKey::MA_NOUVELLE_FEATURE, FeatureState::INCLUDED) ->billable(config('app.plans.{plan}.features.ma-nouvelle-feature')) ->fixed(2900), // 29,00 €
// Feature meteredFeatureKey::MA_NOUVELLE_FEATURE->value => Feature::create( FeatureKey::MA_NOUVELLE_FEATURE, FeatureState::INCLUDED) ->billable(config('app.plans.{plan}.features.ma-nouvelle-feature')) ->metered( includedQuota: 10, // quantité incluse trancheSize: 5, // taille d'une tranche unitPrice: 500, // prix par tranche (5,00 €) stripeMeteredId: 'meter_id' ),
// Feature optionnelle (addon)FeatureKey::MA_NOUVELLE_FEATURE->value => Feature::create( FeatureKey::MA_NOUVELLE_FEATURE, FeatureState::OPTIONAL) ->billable(config('app.plans.{plan}.features.ma-nouvelle-feature')) ->fixed(490), // 4,90 €Pour les plans où la feature n’est pas dispo, ne rien ajouter — APlan::features() la marquera automatiquement UNAVAILABLE.
5. Si c’est un siège utilisateur
Section titled “5. Si c’est un siège utilisateur”Ajouter la logique de comptage dans FeatureKey::isUserSeat() et dans TenantSubscription::updateBillableUsers().
6. Si c’est une feature metered
Section titled “6. Si c’est une feature metered”Ajouter une méthode de sync dans TenantSubscription et l’appeler dans le job UpdateBillingMetricsJob.
7. Mettre à jour le frontend
Section titled “7. Mettre à jour le frontend”- Ajouter la feature dans
webapp/lib/constants.tssi besoin d’un label - Regénérer les types :
make typesà la racine
Guide : Ajouter un Plan
Section titled “Guide : Ajouter un Plan”1. Ajouter la clé dans l’enum
Section titled “1. Ajouter la clé dans l’enum”enum PlanKey: string{ case STARTER = 'starter'; case INDEPENDENT = 'independent'; case MEDICAL_OFFICE = 'medical_office'; case MON_NOUVEAU_PLAN = 'mon_nouveau_plan';}2. Créer la classe du plan
Section titled “2. Créer la classe du plan”Créer src/Domain/Subscription/Plans/MonNouveauPlan.php en étendant APlan :
<?php
namespace Domain\Subscription\Plans;
use Domain\Subscription\APlan;use Domain\Subscription\Enums\FeatureKey;use Domain\Subscription\Enums\FeatureState;use Domain\Subscription\Enums\PlanKey;use Domain\Subscription\Feature;
class MonNouveauPlan extends APlan{ public function key(): PlanKey { return PlanKey::MON_NOUVEAU_PLAN; }
public function price(): int { return 12000; // 120,00 € en centimes }
public function installationFee(): int { return 20000; // 200,00 € }
public function maxQuotaUsers(): ?int { return null; // null = illimité }
public function maxQuotaSites(): ?int { return null; }
public function platformConnectPaymentFixedFees(): int { return 0; }
public function platformConnectPaymentPercentFees(): float { return 0.0; }
protected function featureConfig(): array { return [ FeatureKey::BASE->value => Feature::create(FeatureKey::BASE, FeatureState::INCLUDED) ->billable(config('app.plans.mon_nouveau_plan.features.base')) ->fixed(3900), // ... autres features ]; }}3. Enregistrer le plan dans le service
Section titled “3. Enregistrer le plan dans le service”public static function getPlanFromKey(PlanKey $planKey): APlan{ return match ($planKey) { // ... plans existants PlanKey::MON_NOUVEAU_PLAN => new MonNouveauPlan, };}4. Ajouter les Stripe Price IDs dans la config
Section titled “4. Ajouter les Stripe Price IDs dans la config”// config/app.php → plans'mon_nouveau_plan' => [ 'installation_fee' => env('APP_ENV', 'production') === 'production' ? 'price_PROD_INSTALL_ID' : 'price_STAGING_INSTALL_ID', 'installation_fee_offered' => false, 'features' => [ 'base' => env('APP_ENV', 'production') === 'production' ? 'price_PROD_BASE_ID' : 'price_STAGING_BASE_ID', // ... autres features ],],5. Ajouter dans la PricingTable
Section titled “5. Ajouter dans la PricingTable”// src/Domain/Subscription/Resources/PricingTable.php → toArray()'mon_nouveau_plan' => (new MonNouveauPlan)->toArray(),6. Mettre à jour le frontend
Section titled “6. Mettre à jour le frontend”export const PRICING_PLANS_MAP = { // ... MON_NOUVEAU_PLAN: 'mon_nouveau_plan',}
export const PRICING_PLANS_LANG_MAP = { // ... mon_nouveau_plan: 'Mon Nouveau Plan',}7. Regénérer les types
Section titled “7. Regénérer les types”make typesRoutes API
Section titled “Routes API”| Méthode | Route | Auth | Description |
|---|---|---|---|
GET | /billing/plans | — | Pricing table (public) |
GET | /billing/subscription | auth | Subscription actuelle |
POST | /billing/subscribe | auth | Souscrire un plan |
PUT | /billing/subscription | auth + subscribed | Changer de plan |
DELETE | /billing/unsubscribe | auth | Résilier (trial only) |
POST | /billing/subscription/resume | auth | Reprendre après annulation |
GET | /billing/metrics | auth + subscribed | Métriques d’usage |
GET | /billing/features/enabled | auth + subscribed | Vérifier si une feature est active |
POST | /billing/features | auth + subscribed | Activer un addon |
DELETE | /billing/features | auth + subscribed | Désactiver un addon |
GET | /billing/invoices | auth | Liste des factures |
* | /billing/payment-methods | auth | CRUD moyens de paiement |