Skip to content

Subscriptions

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 Subscription est en base centrale (pas tenant) via CentralConnection

FichierRôle
src/Domain/Subscription/Enums/PlanKey.phpEnum des plans disponibles
src/Domain/Subscription/Enums/FeatureKey.phpEnum de toutes les features
src/Domain/Subscription/Enums/FeatureState.phpÉtats possibles d’une feature
src/Domain/Subscription/IPlan.phpInterface d’un plan
src/Domain/Subscription/APlan.phpClasse abstraite d’un plan
src/Domain/Subscription/Plans/*.phpImplémentation de chaque plan
src/Domain/Subscription/Feature.phpValue object d’une feature
src/Domain/Subscription/Models/Subscription.phpModèle Eloquent (étend Cashier)
src/Domain/Subscription/Concerns/TenantSubscription.phpTrait sur le Tenant pour la gestion billing
src/Domain/Subscription/Services/SubscriptionPlanService.phpRésolution plan key → instance
src/Domain/Subscription/Resources/PricingTable.phpResource API pour la pricing page
config/app.phpplansStripe Price IDs par plan/feature (prod vs staging)
app/Modules/Subscriptions/Controllers/SubscriptionController.phpController principal
webapp/lib/constants.tsPRICING_PLANS_MAP et PRICING_PLANS_LANG_MAP

Le Tenant stocke les informations de souscription directement sur son modèle :

plan_commitment → PlanCommitment (monthly | yearly)
plan_commitment_ends_at → datetime
plan_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)

PlanPlanKeyPrix/moisMax usersMax sitesFrais plateforme
Standardstarter58,80 €1110%
Indépendantindependent88,80 €120%
Cabinetmedical_office105,60 €illimitéillimité0%

Tous ont des frais d’installation de 200 € (actuellement offerts via installation_fee_offered: true).

  • 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_at est dans le futur
  • 15 jours d’essai pour les nouveaux tenants (première souscription uniquement)
  • Résiliation possible uniquement pendant le trial

ÉtatFeatureStateDescription
InclusINCLUDEDInclus dans le plan, automatiquement activé
OptionnelOPTIONALAddon activable/désactivable par le tenant
IndisponibleUNAVAILABLEPas disponible pour ce plan
TypeDescriptionExemple
FixedPrix fixe par unité, quantité ajustée via updateQuantitySièges utilisateurs (10-91 €/siège)
MeteredUsage mesuré par tranches, reporté via reportMeterEventStockage (25 Go/tranche), Sites
GratuitInclus sans facturation StripeDoctolib, FSE, IA, paiements in-app
FeatureFeatureKeyStarterIndependentMedical Office
Basebase39 € (fixed)39 € (fixed)39 € (fixed)
Orthoptisteuser-orthoptist10 €/siège35 €/siège49 €/siège
Ophtalmologueuser-ophthalmologist91 €/siège
Secrétaireuser-secretary41 €/siège
Stockagestorage15 Go inclus25 Go inclus35 Go inclus
Sitessites2 inclus, 39 €/sup
Doctolibaddons-doctolib✓ gratuit✓ gratuit✓ gratuit
FSEaddons-fse✓ gratuit✓ gratuit✓ gratuit
Paiementsin_app-payments✓ gratuit✓ gratuit✓ gratuit
IAaddons-ai-generation✓ gratuit✓ gratuit✓ gratuit
Desktopdesktop-app✓ gratuit✓ gratuit
Ordoclicaddons-ordoclic4,90 € (optional)✓ gratuit

tenant->isFeatureEnabled(FeatureKey) vérifie si une feature est accessible en combinant :

plan_features_base ∪ plan_features_enabled ∪ plan_features_forced

Si subscription_bypass = true, toutes les features sont activées (override admin).

Le middleware subscribed protège les routes nécessitant un abonnement actif.


  1. Vérifie qu’aucune sub active n’existe
  2. Crée une subscription Stripe via Cashier avec les prix fixed + metered + sièges
  3. Trial de 15 jours si première souscription
  4. Applique coupon promo ou réduction annuelle
  5. Facture les frais d’installation séparément (si pas offerts)
  6. Stocke quotas et features sur le tenant
  1. Swap des prix Stripe avec prorations
  2. Vérifie la période d’engagement (yearly → monthly interdit si engagement en cours)
  3. Reporte les features optionnelles compatibles avec le nouveau plan
  4. Applique/retire le coupon annuel
  5. Met à jour quotas et features sur le tenant
  • 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_enabled sur le tenant

Le trait TenantSubscription synchronise les quantités vers Stripe :

MéthodeCe 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

Les webhooks sont routés via StripeEventListenerStripeWebhookService :

Event StripeHandlerAction
customer.subscription.createdCustomerSubscriptionCreatedSync quotas/features sur le tenant
customer.subscription.updatedCustomerSubscriptionUpdated
customer.subscription.deletedCustomerSubscriptionDeleted

Les handlers sont dans src/Infrastructure/Stripe/Webhooks/CustomerSubscription/.


src/Domain/Subscription/Enums/FeatureKey.php
enum FeatureKey: string
{
// ... features existantes
case MA_NOUVELLE_FEATURE = 'ma-nouvelle-feature';
}

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.

// 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 payante
FeatureKey::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 metered
FeatureKey::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.

Ajouter la logique de comptage dans FeatureKey::isUserSeat() et dans TenantSubscription::updateBillableUsers().

Ajouter une méthode de sync dans TenantSubscription et l’appeler dans le job UpdateBillingMetricsJob.

  • Ajouter la feature dans webapp/lib/constants.ts si besoin d’un label
  • Regénérer les types : make types à la racine

src/Domain/Subscription/Enums/PlanKey.php
enum PlanKey: string
{
case STARTER = 'starter';
case INDEPENDENT = 'independent';
case MEDICAL_OFFICE = 'medical_office';
case MON_NOUVEAU_PLAN = 'mon_nouveau_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
];
}
}
src/Domain/Subscription/Services/SubscriptionPlanService.php
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
],
],
// src/Domain/Subscription/Resources/PricingTable.php → toArray()
'mon_nouveau_plan' => (new MonNouveauPlan)->toArray(),
webapp/lib/constants.ts
export const PRICING_PLANS_MAP = {
// ...
MON_NOUVEAU_PLAN: 'mon_nouveau_plan',
}
export const PRICING_PLANS_LANG_MAP = {
// ...
mon_nouveau_plan: 'Mon Nouveau Plan',
}
Terminal window
make types

MéthodeRouteAuthDescription
GET/billing/plansPricing table (public)
GET/billing/subscriptionauthSubscription actuelle
POST/billing/subscribeauthSouscrire un plan
PUT/billing/subscriptionauth + subscribedChanger de plan
DELETE/billing/unsubscribeauthRésilier (trial only)
POST/billing/subscription/resumeauthReprendre après annulation
GET/billing/metricsauth + subscribedMétriques d’usage
GET/billing/features/enabledauth + subscribedVérifier si une feature est active
POST/billing/featuresauth + subscribedActiver un addon
DELETE/billing/featuresauth + subscribedDésactiver un addon
GET/billing/invoicesauthListe des factures
*/billing/payment-methodsauthCRUD moyens de paiement