Remplace l'ancien pricing (DictIA 8 / 16 / Cloud) par la nouvelle structure canonique v7.0 : 4 forfaits + 1 sentinel quote-only. Changements clés : - pricing_card.html : signature étendue (badge, recommended, capacity_audio, capacity_storage, gpu, yearly_renewal, cta_label) + format prix server-side avec NBSP OQLF (5998 -> 5 998 $) - _pricing_tiers.html : 4 cards (Cloud Basic 189$, Cloud Essentiel 349$, Cloud Pro 549$+485$ RECOMMANDÉ, DictIA Local 5998$ An1) + chip Pro+ soumission -> /contact?pro-plus=1 - plans.py : refonte complète avec yearly_renewal_env (DictIA Local An 2+ = 500$/an) + is_quote_only sentinel (Pro+ -> redirect /contact, jamais Stripe) - routes.py : Pro+ intercepté avant le flow Stripe Checkout - env.stripe.example : nouveau naming STRIPE_CLOUD_BASIC|ESSENTIEL|PRO_* + STRIPE_DICTIA_LOCAL_SETUP/RENEWAL_YEARLY - tarifs.html : header "Quatre forfaits", matrice comparative 4 colonnes, FAQ enrichie (7 questions incluant DictIA Local + onboarding Pro + Pro+) - fonctionnalites.html : section Architecture refondue (4 cards v7.0) - landing.html : ROI footnote + cycle "189$" + wave "189$/mois" actualisés - roi_calculator.js : recalibrage sur Cloud ESSENTIEL 349$ × 12 = 4188$/an - routes.py marketing : FAQ "DictIA 8 et 16" -> "DictIA LOCAL" - contact.html : "déploiements DictIA 16" -> "Cloud PRO" + "DictIA LOCAL" Tests : - test_marketing_landing_template.py : assertions prix v7.0 (189/349/549/5998), 4 slugs (cloud-basic, cloud-essentiel, cloud-pro, dictia-local), Pro+ chip, capacity chips, RECOMMANDÉ sur Cloud PRO - test_marketing_secondary_pages.py : 4 cards + Pro+ chip + matrice 4 col + FAQ 7 questions - test_stripe_checkout.py : env vars v7.0, slugs cloud-basic/cloud-pro/ dictia-local + nouveau test pro-plus -> /contact + tests setup pour Cloud PRO et DictIA Local - test_stripe_webhook.py : plan_slug metadata cloud-basic Status : 28/28 Stripe checkout + 17/17 webhook + 93/98 marketing pass (les 5 marketing failures sont pré-existantes, non liées au pricing : test_landing_has_main_nav et test_footer_links_complete = /blog manquant ; test_trust_bar_has_eyebrow_factual_phrasing + 2 tests conformite = casing eyebrow + entité é — vérifié par git stash baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
6.6 KiB
Python
164 lines
6.6 KiB
Python
"""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_<PLAN>_<PERIOD> 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())
|