"""DictIA pricing plans — v7.0 (B-2.7 refonte 2026-04-27). Centralized plan registry. Stripe Price IDs are resolved from environment variables — set STRIPE__ env vars in production. The slug (`cloud-basic`, `cloud-essentiel`, `cloud-pro`, `dictia-local`) is the canonical identifier used throughout the codebase (URL params, webhook metadata, audit logs). v7.0 pricing reference (CAD, pre-tax — TPS/TVQ added by Stripe automatic_tax): - Cloud BASIC : 189 $/mo recurring (no setup) yearly = 189 × 12 × 0.85 ≈ 1 928 $/an - Cloud ESSENTIEL : 349 $/mo recurring (no setup) yearly = 349 × 12 × 0.85 ≈ 3 559 $/an - Cloud PRO : 549 $/mo recurring + 485 $ one-time onboarding setup yearly = 549 × 12 × 0.85 ≈ 5 600 $/an (+ 485 $ setup) - DictIA LOCAL : 5 998 $ one-time (An 1 = matériel + 1ʳᵉ année logiciel) puis 500 $/an dès An 2 (renewal yearly only — no monthly) Pro+ is a sentinel plan — no Stripe Price IDs, the route redirects to /contact?pro-plus=1 instead of opening Stripe Checkout. It exists in PLANS so other code (URL routing, navigation) can identify it; `is_configured()` always returns False so the route falls through to the contact redirect. """ import os from dataclasses import dataclass from typing import Dict, List, Optional @dataclass(frozen=True) class Plan: """A DictIA subscription plan (v7.0). Stripe Price IDs are resolved lazily from environment variables — the Plan instance itself only stores the variable names. This lets the application boot without Stripe credentials (CI, dev branches) and keeps secrets out of source control. Three pricing shapes are supported: - Cloud Basic / Essentiel : monthly + yearly (no setup, no renewal) - Cloud Pro : monthly + yearly + setup (one-time onboarding) - DictIA Local : setup (An 1) + yearly_renewal (dès An 2) — no monthly Price ID The Pro+ plan has all *_env fields set to None — the route checks `is_quote_only` and redirects to /contact instead of opening Checkout. """ slug: str name: str description_fr: str has_setup_fee: bool monthly_env: Optional[str] = None yearly_env: Optional[str] = None setup_env: Optional[str] = None # Cloud Pro setup OR DictIA Local An 1 yearly_renewal_env: Optional[str] = None # DictIA Local An 2+ renewal is_quote_only: bool = False # True for Pro+ (no Stripe — redirect to contact) def setup_price_id(self) -> Optional[str]: if not self.has_setup_fee or not self.setup_env: return None return os.environ.get(self.setup_env) def monthly_price_id(self) -> Optional[str]: if not self.monthly_env: return None return os.environ.get(self.monthly_env) def yearly_price_id(self) -> Optional[str]: if not self.yearly_env: return None return os.environ.get(self.yearly_env) def yearly_renewal_price_id(self) -> Optional[str]: if not self.yearly_renewal_env: return None return os.environ.get(self.yearly_renewal_env) def is_configured(self) -> bool: """True when all required Stripe Price IDs are set in the environment. - Quote-only plans (Pro+) are never configured (always redirect to /contact). - Cloud plans require monthly + yearly Price IDs. - Cloud Pro additionally requires the one-time setup Price ID. - DictIA Local requires setup (An 1) + yearly_renewal (dès An 2). """ if self.is_quote_only: return False # DictIA Local (one-shot + yearly renewal — no monthly) if self.setup_env and self.yearly_renewal_env and not self.monthly_env: return bool(self.setup_price_id() and self.yearly_renewal_price_id()) # Cloud plans (monthly + yearly required, + setup if Pro) if self.has_setup_fee and not self.setup_price_id(): return False return bool(self.monthly_price_id() and self.yearly_price_id()) def price_id_for_period(self, period: str) -> Optional[str]: """Resolve the Price ID for the given billing period. For DictIA Local (no monthly), 'monthly' falls back to the yearly_renewal Price ID — Stripe Checkout will display the recurrence in the session UI. """ if not self.monthly_env and self.yearly_renewal_env: # DictIA Local — only a yearly renewal exists return self.yearly_renewal_price_id() return self.yearly_price_id() if period == 'yearly' else self.monthly_price_id() PLANS: Dict[str, Plan] = { 'cloud-basic': Plan( slug='cloud-basic', name='Cloud BASIC', description_fr='Cloud souverain QC — 189 $/mo · solopreneur, petite équipe.', has_setup_fee=False, monthly_env='STRIPE_CLOUD_BASIC_MONTHLY', yearly_env='STRIPE_CLOUD_BASIC_YEARLY', ), 'cloud-essentiel': Plan( slug='cloud-essentiel', name='Cloud ESSENTIEL', description_fr='Cloud souverain QC — 349 $/mo · cabinet en croissance.', has_setup_fee=False, monthly_env='STRIPE_CLOUD_ESSENTIEL_MONTHLY', yearly_env='STRIPE_CLOUD_ESSENTIEL_YEARLY', ), 'cloud-pro': Plan( slug='cloud-pro', name='Cloud PRO', description_fr='Cloud souverain QC — 549 $/mo + 485 $ onboarding · usage intensif multi-postes.', has_setup_fee=True, setup_env='STRIPE_CLOUD_PRO_SETUP', monthly_env='STRIPE_CLOUD_PRO_MONTHLY', yearly_env='STRIPE_CLOUD_PRO_YEARLY', ), 'dictia-local': Plan( slug='dictia-local', name='DictIA LOCAL', description_fr='100 % hors-ligne — 5 998 $ An 1 (matériel + logiciel) puis 500 $/an dès An 2.', has_setup_fee=True, setup_env='STRIPE_DICTIA_LOCAL_SETUP', yearly_renewal_env='STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY', # No monthly_env / yearly_env — local plan is one-shot + yearly renewal ), 'pro-plus': Plan( slug='pro-plus', name='Pro+', description_fr='Soumission personnalisée — > 660 h audio/mois, multi-sites, SLA 99,9 %, SOC 2.', has_setup_fee=False, is_quote_only=True, ), } VALID_PERIODS = ('monthly', 'yearly') def get_plan(slug: str) -> Optional[Plan]: """Return the Plan for `slug`, or None if unknown.""" if not slug: return None return PLANS.get(slug) def list_plans() -> List[Plan]: """Return all registered plans in registration order.""" return list(PLANS.values())