refactor(pricing): refonte v7.0 — 3 Cloud (Basic 189$/Essentiel 349$/Pro 549$) + DictIA Local (5998$ An1) + Pro+ soumission

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>
This commit is contained in:
Allison
2026-04-28 21:06:12 -04:00
parent e8c7e5cd43
commit 1c4cafaf69
16 changed files with 648 additions and 301 deletions

View File

@@ -1,5 +1,5 @@
###############################################################################
# Stripe — Checkout + Subscriptions (B-2.7 / B-2.8)
# Stripe — Checkout + Subscriptions (B-2.7 / B-2.8) — v7.0 pricing
###############################################################################
#
# Required for the /checkout/<plan> flow and the /webhooks/stripe receiver.
@@ -15,30 +15,34 @@
# STRIPE_WEBHOOK_SECRET=whsec_... # for B-2.8 webhook signature verification
###############################################################################
# Price IDs — one per plan, period, and (for hardware plans) setup fee.
# Price IDs — v7.0 (Cloud Basic / Essentiel / Pro + DictIA Local)
###############################################################################
#
# Format: price_xxxxxxxxxxxxxxxxxxxxxxxxxx
# Naming convention in this codebase: STRIPE_<PLAN>_<TYPE>
# PLAN = DICTIA_8 | DICTIA_16 | DICTIA_CLOUD
# TYPE = SETUP (one-time, hardware only) | MONTHLY | YEARLY
# PLAN = CLOUD_BASIC | CLOUD_ESSENTIEL | CLOUD_PRO | DICTIA_LOCAL
# TYPE = SETUP (one-time) | MONTHLY | YEARLY | RENEWAL_YEARLY (DictIA Local An 2+)
#
# Yearly Price = Monthly Price × 12 × 0.85 (15 % discount). Configure both
# Prices in the Stripe Dashboard for each plan.
# Prices in the Stripe Dashboard for each Cloud plan.
# Pro+ is quote-only — NO Stripe Price IDs (the route redirects to /contact).
# DictIA 8 (8-channel hardware bundle): 3 450 $ setup + 173 $/mo
# STRIPE_DICTIA_8_SETUP=price_xxx
# STRIPE_DICTIA_8_MONTHLY=price_xxx
# STRIPE_DICTIA_8_YEARLY=price_xxx
# Cloud BASIC : 189 $/mo (no setup) — solopreneur, petite équipe, ~165 h audio/mo
# STRIPE_CLOUD_BASIC_MONTHLY=price_xxx
# STRIPE_CLOUD_BASIC_YEARLY=price_xxx
# DictIA 16 (16-channel hardware bundle): 5 750 $ setup + 201 $/mo
# STRIPE_DICTIA_16_SETUP=price_xxx
# STRIPE_DICTIA_16_MONTHLY=price_xxx
# STRIPE_DICTIA_16_YEARLY=price_xxx
# Cloud ESSENTIEL : 349 $/mo (no setup) — cabinet en croissance, ~330 h audio/mo
# STRIPE_CLOUD_ESSENTIEL_MONTHLY=price_xxx
# STRIPE_CLOUD_ESSENTIEL_YEARLY=price_xxx
# DictIA Cloud (SaaS-only, no hardware): 369 $/mo
# STRIPE_DICTIA_CLOUD_MONTHLY=price_xxx
# STRIPE_DICTIA_CLOUD_YEARLY=price_xxx
# Cloud PRO : 549 $/mo + 485 $ onboarding (one-time) — usage intensif, ~660 h audio/mo
# STRIPE_CLOUD_PRO_SETUP=price_xxx
# STRIPE_CLOUD_PRO_MONTHLY=price_xxx
# STRIPE_CLOUD_PRO_YEARLY=price_xxx
# DictIA LOCAL : 5 998 $ An 1 (one-time matériel + 1ère année logiciel) puis 500 $/an dès An 2
# STRIPE_DICTIA_LOCAL_SETUP=price_xxx
# STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY=price_xxx
###############################################################################
# Required Stripe Dashboard configuration
@@ -56,10 +60,13 @@
# Apple Pay requires verifying the dictia.ca domain via the Stripe-hosted
# `.well-known/apple-developer-merchantid-domain-association` file.
#
# 4. For each plan, create:
# 4. For each Cloud plan, create:
# - One recurring monthly Price (CAD, billing_scheme=per_unit)
# - One recurring yearly Price (CAD, = monthly × 12 × 0.85)
# For DictIA 8 and DictIA 16, also create a one-time Price for the setup fee.
# For Cloud PRO, also create a one-time Price for the 485 $ setup fee.
# For DictIA LOCAL, create:
# - One one-time Price for 5 998 $ (An 1 — matériel + logiciel)
# - One recurring yearly Price for 500 $ (renewal — MAJ + support dès An 2)
#
# 5. Create a webhook endpoint (B-2.8) pointing at
# https://your-domain.example/checkout/webhooks/stripe

View File

@@ -1,14 +1,25 @@
"""DictIA pricing plans (B-2.7).
"""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
(`dictia-8`, `dictia-16`, `dictia-cloud`) is the canonical identifier
used throughout the codebase (URL params, webhook metadata, audit logs).
(`cloud-basic`, `cloud-essentiel`, `cloud-pro`, `dictia-local`) is the
canonical identifier used throughout the codebase (URL params, webhook
metadata, audit logs).
Pricing reference (CAD, pre-tax — TPS/TVQ added by Stripe automatic_tax):
- DictIA 8: 3 450$ setup (one-time) + 173$/mo recurring (or yearly = 173 × 12 × 0.85)
- DictIA 16: 5 750$ setup (one-time) + 201$/mo recurring (or yearly = 201 × 12 × 0.85)
- DictIA Cloud: 369$/mo recurring (or yearly = 369 × 12 × 0.85)
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
@@ -17,20 +28,31 @@ from typing import Dict, List, Optional
@dataclass(frozen=True)
class Plan:
"""A DictIA subscription 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: str
yearly_env: str
setup_env: Optional[str] = None # only set for plans with a setup fee
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:
@@ -38,47 +60,91 @@ class Plan:
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."""
"""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] = {
'dictia-8': Plan(
slug='dictia-8',
name='DictIA 8',
description_fr='Boîtier 8 canaux + transcription IA locale (poste de travail).',
has_setup_fee=True,
setup_env='STRIPE_DICTIA_8_SETUP',
monthly_env='STRIPE_DICTIA_8_MONTHLY',
yearly_env='STRIPE_DICTIA_8_YEARLY',
),
'dictia-16': Plan(
slug='dictia-16',
name='DictIA 16',
description_fr='Boîtier 16 canaux + transcription IA locale (salle de réunion).',
has_setup_fee=True,
setup_env='STRIPE_DICTIA_16_SETUP',
monthly_env='STRIPE_DICTIA_16_MONTHLY',
yearly_env='STRIPE_DICTIA_16_YEARLY',
),
'dictia-cloud': Plan(
slug='dictia-cloud',
name='DictIA Cloud',
description_fr='Transcription IA hébergée au Québec, 100% conforme Loi 25.',
'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_DICTIA_CLOUD_MONTHLY',
yearly_env='STRIPE_DICTIA_CLOUD_YEARLY',
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,
),
}

View File

@@ -42,6 +42,10 @@ def checkout(plan):
flash('Forfait inconnu.', 'danger')
return redirect(url_for('marketing.tarifs'))
# Pro+ — soumission personnalisée (no Stripe Checkout, redirect to /contact)
if plan_obj.is_quote_only:
return redirect(url_for('marketing.contact') + '?pro-plus=1')
period = request.args.get('period', 'monthly')
if period not in VALID_PERIODS:
period = 'monthly'

View File

@@ -37,7 +37,7 @@ TESTIMONIALS = [
FAQ = [
{
'q': 'Comment fonctionne la transcription?',
'a': 'DictIA utilise WhisperX Large-v3, le moteur de transcription de pointe d\'OpenAI, exécuté directement sur le GPU local (DictIA&nbsp;8 et&nbsp;16) ou sur un GPU cloud dédié au Québec (DictIA Cloud). Vous téléversez un fichier audio ou vidéo, et la transcription est générée automatiquement avec identification des locuteurs. Pour la conformité Loi&nbsp;25, l\'audit trail (art.&nbsp;3.5 LPRPSP), le registre des consentements (art.&nbsp;14) et l\'EFVP (art.&nbsp;3.3) sont fournis par défaut.',
'a': 'DictIA utilise WhisperX Large-v3, le moteur de transcription de pointe d\'OpenAI, exécuté soit sur un GPU dédié au Québec (forfaits Cloud BASIC, ESSENTIEL, PRO — OVH Beauharnois) soit directement sur votre GPU local (DictIA&nbsp;LOCAL — RTX 5070&nbsp;Ti chez vous). Vous téléversez un fichier audio ou vidéo, et la transcription est générée automatiquement avec identification des locuteurs. Pour la conformité Loi&nbsp;25, l\'audit trail (art.&nbsp;3.5 LPRPSP), le registre des consentements (art.&nbsp;14) et l\'EFVP (art.&nbsp;3.3) sont fournis par défaut.',
},
{
'q': 'Quels formats audio/vidéo sont supportés?',
@@ -49,7 +49,7 @@ FAQ = [
},
{
'q': 'La transcription est-elle vraiment confidentielle?',
'a': 'Avec DictIA&nbsp;8 et&nbsp;16, vos données ne quittent jamais votre bureau — le traitement est 100&nbsp;% local, sans connexion internet requise. Avec DictIA Cloud, les données sont hébergées exclusivement au Canada (OVH Beauharnois, QC et GCP Toronto, ON). Aucun transfert hors-frontières, zéro Cloud Act.',
'a': 'Avec DictIA&nbsp;LOCAL, vos données ne quittent jamais votre bureau — le traitement est 100&nbsp;% local, sans connexion internet requise. Avec les forfaits Cloud (BASIC, ESSENTIEL, PRO), les données sont hébergées exclusivement au Québec (OVH Beauharnois). Aucun transfert hors-frontières, zéro Cloud Act.',
},
{
'q': 'Teams Copilot est-il légal pour mes réunions?',

View File

@@ -49,8 +49,6 @@
--color-green-700: oklch(52.7% 0.154 150.069);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-900: oklch(39.3% 0.095 152.535);
--color-emerald-50: oklch(97.9% 0.021 166.113);
--color-emerald-100: oklch(95% 0.052 163.051);
--color-emerald-500: oklch(69.6% 0.17 162.48);
--color-emerald-600: oklch(59.6% 0.145 163.225);
--color-emerald-700: oklch(50.8% 0.118 165.612);
@@ -1169,12 +1167,18 @@
.min-w-\[180px\] {
min-width: 180px;
}
.min-w-\[260px\] {
min-width: 260px;
}
.min-w-\[300px\] {
min-width: 300px;
}
.min-w-\[720px\] {
min-width: 720px;
}
.min-w-\[820px\] {
min-width: 820px;
}
.min-w-full {
min-width: 100%;
}
@@ -1765,6 +1769,9 @@
.border-brand-b3\/15 {
border-color: color-mix(in oklab, #c026d3 15%, transparent);
}
.border-brand-b3\/20 {
border-color: color-mix(in oklab, #c026d3 20%, transparent);
}
.border-brand-b3\/60 {
border-color: color-mix(in oklab, #c026d3 60%, transparent);
}
@@ -2074,6 +2081,9 @@
.bg-brand-b2\/20 {
background-color: color-mix(in oklab, #06b6d4 20%, transparent);
}
.bg-brand-b2\/\[0\.08\] {
background-color: color-mix(in oklab, #06b6d4 8%, transparent);
}
.bg-brand-b3 {
background-color: #c026d3;
}
@@ -2083,6 +2093,9 @@
.bg-brand-b3\/60 {
background-color: color-mix(in oklab, #c026d3 60%, transparent);
}
.bg-brand-b3\/\[0\.08\] {
background-color: color-mix(in oklab, #c026d3 8%, transparent);
}
.bg-brand-bg {
background-color: #f7f9fc;
}
@@ -3304,6 +3317,12 @@
color: color-mix(in oklab, var(--color-white) 70%, transparent);
}
}
.text-white\/75 {
color: color-mix(in srgb, #fff 75%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-white) 75%, transparent);
}
}
.text-white\/80 {
color: color-mix(in srgb, #fff 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {

View File

@@ -1,8 +1,8 @@
// ROI calculator for DictIA pricing section.
// ROI calculator for DictIA pricing section (v7.0).
// Hypotheses transparentes (cf. footnote dans landing.html) :
// - 80% du temps de transcription manuelle est économisé
// - 220 jours ouvrables/an
// - Coût annuel comparé = DictIA 16 = 5 750 $ + (201 $ × 12) = 8 162 $
// - Coût annuel comparé = Cloud ESSENTIEL = 349 $ × 12 = 4 188 $
window.roiCalculator = function roiCalculator() {
return {
users: 5,
@@ -13,7 +13,8 @@ window.roiCalculator = function roiCalculator() {
return Math.round(hoursSaved * this.rate);
},
get payback() {
const annualCost = 5750 + (201 * 12);
// Cloud ESSENTIEL annual cost (no setup fee)
const annualCost = 349 * 12;
if (this.savings <= 0) return null;
return (annualCost / this.savings) * 12;
}

View File

@@ -1,32 +1,105 @@
{# Reusable pricing card macro. FlexiHub style — recommended tier gets a grad-bg outer border (1.5px gradient frame).
{# Reusable pricing card macro (v7.0). FlexiHub style — recommended tier gets a grad-bg outer border (1.5px gradient frame).
Args:
slug : URL-safe identifier (goes into href, NOT piped through | safe — autoescape protects URL)
name : Display name (piped through | safe — may contain entities; current names like "DictIA 8" are entity-free)
price_setup : Setup price string with NBSP (e.g. '3&nbsp;450&nbsp;$') — piped through | safe
price_monthly : Monthly price string with NBSP (e.g. '173&nbsp;$') — piped through | safe
target : Target audience tagline — piped through | safe (may contain entities)
features : List of feature strings, each piped through | safe (will contain &nbsp; entities)
recommended : If True, wraps the card in grad-bg gradient frame + RECOMMANDÉ badge
cta_url : Base URL for the CTA — slug appended (NOT piped through | safe — URL injection guard)
slug : URL-safe identifier (goes into href, NOT piped through | safe — autoescape protects URL)
name : Display name (piped through | safe — entity-free expected: "Cloud BASIC", "DictIA LOCAL"…)
target : Target audience tagline — piped through | safe (may contain entities)
features : List of feature strings, each piped through | safe (may contain &nbsp; entities)
badge : Top eyebrow chip text above the title — e.g. 'Cloud · Souverain QC' or 'Local · 100% hors-ligne'
recommended : If True, wraps the card in grad-bg gradient frame + RECOMMANDÉ badge
setup : One-shot setup price NUMBER (CAD, no NBSP) — None to hide. Cloud Basic/Essentiel = None,
Cloud Pro = 485, DictIA Local = 5998.
monthly : Monthly recurring price NUMBER (CAD, no NBSP) — None for DictIA Local (one-shot only).
yearly_renewal : Year-2+ renewal NUMBER (CAD, no NBSP) — only for DictIA Local (500$/an dès An 2).
capacity_audio : Capacity chip (audio hours / month) — e.g. '~165&nbsp;h audio/mois'
capacity_storage : Capacity chip (storage) — e.g. '100&nbsp;Go'
gpu : GPU chip — e.g. 'NVIDIA L4 partagé'
cta_label : Button text — e.g. 'Démarrer en Cloud', 'Configurer DictIA Local'
cta_url : Base URL for the CTA — slug appended (NOT piped through | safe — URL injection guard)
Note: CTA label is "Réserver [name]" not "Choisir" because product is in pre-launch
(cf. trust bar "Pré-inscription ouverte") — LPC art. 219 hygiene.
The button macro autoescapes its `text` arg, so `name` MUST NOT contain HTML entities
(verified: "DictIA 8", "DictIA 16", "DictIA Cloud" are all entity-free). #}
{%- macro pricing_card(slug, name, price_setup, price_monthly, target, features, recommended=False, cta_url='/checkout') -%}
<div class="relative {% if recommended %}grad-bg p-[1.5px] rounded{% endif %}">
The numeric `setup` / `monthly` / `yearly_renewal` are formatted server-side
with French (fr-CA) thousands separator (NBSP) — `5998` → `5&nbsp;998&nbsp;$`.
This avoids requiring callers to remember OQLF NBSP conventions for every
price string.
Note: pre-launch hygiene (LPC art. 219) — CTA wording is supplied by the
caller (`cta_label`) so we no longer hardcode "Réserver" everywhere. #}
{# Format an integer like 5998 → '5&nbsp;998' (OQLF thousands separator) #}
{%- macro fmt_price(n) -%}
{%- set s = n | string -%}
{%- if s | length > 3 -%}{{ s[:-3] }}&nbsp;{{ s[-3:] }}{%- else -%}{{ s }}{%- endif -%}
{%- endmacro -%}
{%- macro pricing_card(slug, name, target, features,
badge=None, recommended=False,
setup=None, monthly=None, yearly_renewal=None,
capacity_audio=None, capacity_storage=None, gpu=None,
cta_label='Choisir ce forfait', cta_url='/checkout') -%}
<div class="relative {% if recommended %}grad-bg p-[1.5px] rounded shadow-cta{% endif %}">
{% if recommended %}<span class="absolute -top-3 left-1/2 -translate-x-1/2 grad-bg text-white text-xs font-bold px-3 py-1 rounded-full shadow-cta inline-flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3 h-3" aria-hidden="true"><path d="M12 2l2.9 6.9L22 10l-5.5 4.8L18 22l-6-3.6L6 22l1.5-7.2L2 10l7.1-1.1z"/></svg>RECOMMANDÉ</span>{% endif %}
<div class="bg-white p-8 rounded border border-brand-border h-full flex flex-col">
<div class="mb-6">
<h3 class="text-xl font-black mb-1 text-brand-navy">{{ name | safe }}</h3>
<p class="text-sm text-brand-navy/70">{{ target | safe }}</p>
<div class="bg-white p-6 rounded border border-brand-border h-full flex flex-col">
{# Eyebrow badge (optional) — Cloud · Souverain QC / Local · 100% hors-ligne #}
{% if badge %}
<p class="eyebrow grad-text mb-3 text-[11px]">{{ badge | safe }}</p>
{% endif %}
{# Title + target audience #}
<div class="mb-5">
<h3 class="text-xl font-black mb-1.5 text-brand-navy">{{ name | safe }}</h3>
<p class="text-sm text-brand-navy/70 leading-snug">{{ target | safe }}</p>
</div>
<div class="mb-6">
<div class="text-4xl font-black grad-text">{{ price_setup | safe }}</div>
<div class="text-sm text-brand-navy/70">+ {{ price_monthly | safe }} / mois</div>
{# Pricing block — 3 layouts:
- DictIA Local : setup (one-shot An 1) + yearly_renewal tagline
- Cloud Pro : setup (one-time onboarding) + monthly recurring
- Cloud Basic/Essentiel : monthly only #}
<div class="mb-5 pb-5 border-b border-brand-border">
{% if monthly is none and setup is not none %}
{# DictIA Local — one-shot An 1 + yearly renewal #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(setup) }}&nbsp;$</div>
<div class="text-xs text-brand-navy/70 mt-2">An&nbsp;1 (matériel + installation + 1<sup>re</sup> année logiciel)</div>
{% if yearly_renewal %}
<div class="text-xs text-brand-navy/70 mt-1">puis <strong class="text-brand-navy">{{ fmt_price(yearly_renewal) }}&nbsp;$/an</strong> dès An&nbsp;2</div>
{% endif %}
{% elif setup is not none and monthly is not none %}
{# Cloud Pro — setup + monthly #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }}&nbsp;$<span class="text-base text-brand-navy/60 font-bold">&nbsp;/&nbsp;mois</span></div>
<div class="text-xs text-brand-navy/70 mt-2">+ {{ fmt_price(setup) }}&nbsp;$ onboarding (unique)</div>
{% else %}
{# Cloud Basic / Essentiel — monthly only #}
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }}&nbsp;$<span class="text-base text-brand-navy/60 font-bold">&nbsp;/&nbsp;mois</span></div>
<div class="text-xs text-brand-navy/70 mt-2">Aucun frais d'installation</div>
{% endif %}
</div>
<ul class="space-y-3 mb-8 flex-grow">
{# Capacity chips — audio / storage / GPU (only if provided) #}
{% if capacity_audio or capacity_storage or gpu %}
<div class="flex flex-wrap gap-1.5 mb-5" role="list" aria-label="Caractéristiques techniques">
{% if capacity_audio %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b1/[0.08] border border-brand-b1/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b1" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>{{ capacity_audio | safe }}</span>
</span>
{% endif %}
{% if capacity_storage %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b2/[0.08] border border-brand-b2/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b2" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>
<span>{{ capacity_storage | safe }}</span>
</span>
{% endif %}
{% if gpu %}
<span role="listitem" class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-brand-b3/[0.08] border border-brand-b3/20 text-[11px] font-semibold text-brand-navy/85">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3 h-3 text-brand-b3" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 18v2"/><path d="M18 18v2"/><path d="M6 10h.01"/><path d="M10 10h4"/></svg>
<span>{{ gpu | safe }}</span>
</span>
{% endif %}
</div>
{% endif %}
{# Features list #}
<ul class="space-y-2.5 mb-6 flex-grow" role="list">
{% for f in features %}
<li class="flex items-start gap-2 text-sm text-brand-navy/80">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 mt-0.5 flex-shrink-0 text-brand-b3" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
@@ -34,8 +107,10 @@
</li>
{% endfor %}
</ul>
{# CTA #}
{% from 'macros/button.html' import button %}
{{ button('Réserver ' + name, href=cta_url.rstrip('/') + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }}
{{ button(cta_label, href=cta_url.rstrip('/') + '/' + slug, variant='primary' if recommended else 'secondary', size='lg') }}
</div>
</div>
{%- endmacro -%}

View File

@@ -1,30 +1,113 @@
{# Single source of truth for the 3 pricing tiers — used by landing.html#tarifs and /tarifs page.
When prices change, edit ONLY this file. #}
{# Single source of truth for the v7.0 pricing — used by landing.html#tarifs and /tarifs page.
When prices change, edit ONLY this file (and src/billing/plans.py for Stripe IDs).
v7.0 — 4 forfaits + 1 soumission :
- Cloud BASIC 189 $/mois (no setup)
- Cloud ESSENTIEL 349 $/mois (no setup)
- Cloud PRO 549 $/mois + 485 $ onboarding (recommended)
- DictIA LOCAL 5 998 $ An 1 puis 500 $/an dès An 2 (no monthly)
- Pro+ soumission personnalisée → /contact?pro-plus=1
Common to all forfaits :
WhisperX Large-v3 (99%+ · 99+ langues), pyannote diarisation, Mistral résumés,
exports SRT/VTT/TXT/JSON/DOCX, Loi 25 conforme, OVH Beauharnois (Cloud) ou local. #}
{% from 'macros/pricing_card.html' import pricing_card %}
<div class="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto items-stretch">
{%- set _baseline_features_cloud = [
'WhisperX Large-v3 · 99&nbsp;%+ précision · 99+ langues',
'Diarisation pyannote (qui parle)',
'Résumés IA + Points d&rsquo;action (Mistral Nemo 12B)',
'Exports SRT, VTT, TXT, JSON, DOCX',
'Hébergement OVH Beauharnois (QC)',
'Conforme Loi&nbsp;25 · Anti-DDoS · Backups quotidiens',
'Aucune limite utilisateurs'
] -%}
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{{ pricing_card(
'dictia-8',
'DictIA 8',
'3&nbsp;450&nbsp;$',
'173&nbsp;$',
'PME · Manufacturiers · RH · Services — local, vos données ne quittent jamais votre bureau.',
['GPU 8&nbsp;Go RTX', 'Volume illimité', 'WhisperX FR-CA', 'Diarisation 8 locuteurs', 'Support inclus']
slug='cloud-basic',
name='Cloud BASIC',
badge='Cloud · Souverain QC',
target='Solopreneur · petite équipe · usage occasionnel à régulier.',
monthly=189,
capacity_audio='~165&nbsp;h audio/mois',
capacity_storage='100&nbsp;Go',
gpu='NVIDIA L4 partagé',
features=_baseline_features_cloud,
cta_label='Démarrer en Cloud'
) }}
{{ pricing_card(
'dictia-16',
'DictIA 16',
'5&nbsp;750&nbsp;$',
'201&nbsp;$',
'Cabinets juridiques · CPA · Services financiers — local, Mistral&nbsp;7B sur votre GPU.',
['GPU 16&nbsp;Go RTX', 'Mistral 7B local', 'Q&amp;R sur enregistrement', 'Tout DictIA 8', 'Support prioritaire'],
recommended=True
slug='cloud-essentiel',
name='Cloud ESSENTIEL',
badge='Cloud · Souverain QC',
target='Cabinet en croissance · usage quotidien soutenu.',
monthly=349,
capacity_audio='~330&nbsp;h audio/mois',
capacity_storage='200&nbsp;Go',
gpu='NVIDIA L4 partagé étendu',
features=_baseline_features_cloud,
cta_label='Choisir Essentiel'
) }}
{{ pricing_card(
'dictia-cloud',
'DictIA Cloud',
'0&nbsp;$',
'369&nbsp;$',
'Organismes · Municipalités · Multi-sites — Cloud QC, opérationnel en 48&nbsp;h, aucun matériel requis.',
['Hébergé OVH Beauharnois (Québec)', 'Opérationnel sous 48&nbsp;h', 'Aucun matériel à gérer', 'SLA visé 99,9&nbsp;%', 'Conforme Loi&nbsp;25']
slug='cloud-pro',
name='Cloud PRO',
badge='Cloud · Souverain QC',
recommended=True,
target='Organisation établie · usage intensif multi-postes.',
setup=485,
monthly=549,
capacity_audio='~660&nbsp;h audio/mois',
capacity_storage='500&nbsp;Go',
gpu='NVIDIA L4 dédié priorité',
features=_baseline_features_cloud + [
'GPU dédié priorité (latence garantie)',
'Onboarding assisté inclus'
],
cta_label='Commander Pro'
) }}
{{ pricing_card(
slug='dictia-local',
name='DictIA LOCAL',
badge='Local · 100&nbsp;% hors-ligne',
target='Confidentialité maximale · 100&nbsp;% hors-ligne chez vous.',
setup=5998,
yearly_renewal=500,
capacity_audio='~1&nbsp;100&nbsp;h audio/mois',
capacity_storage='2&nbsp;To SSD',
gpu='RTX 5070&nbsp;Ti 16&nbsp;Go (dédié)',
features=[
'WhisperX Large-v3 · 99&nbsp;%+ précision · 99+ langues',
'Diarisation pyannote (qui parle)',
'Résumés IA + Points d&rsquo;action (Mistral 7B local)',
'Exports SRT, VTT, TXT, JSON, DOCX',
'GPU local dédié · transcription locale',
'Données jamais sortantes (chez vous)',
'500&nbsp;$/an dès An&nbsp;2 (MAJ + support)',
'Aucune limite utilisateurs'
],
cta_label='Configurer DictIA Local'
) }}
</div>
{# Pro+ banner — soumission personnalisée pour grands volumes / SLA renforcé #}
<div class="mt-10 max-w-5xl mx-auto p-6 bg-brand-navy text-white border border-brand-b2/30 rounded backdrop-blur-sm relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none opacity-60" aria-hidden="true"
style="background: radial-gradient(circle at 100% 0%, rgba(192,38,211,0.12) 0%, transparent 55%), radial-gradient(circle at 0% 100%, rgba(6,182,212,0.10) 0%, transparent 55%);"></div>
<div class="relative flex items-center justify-between flex-wrap gap-6">
<div class="flex-1 min-w-[260px]">
<p class="eyebrow grad-text mb-2 text-[11px]">Pro+ · Soumission personnalisée</p>
<h3 class="text-lg font-bold text-white mb-2">Au-delà de Cloud&nbsp;PRO&nbsp;?</h3>
<p class="text-sm text-white/75 leading-relaxed">
&gt;&nbsp;660&nbsp;h audio/mois · &gt;&nbsp;500&nbsp;Go stockage · 7+ utilisateurs intensifs · multi-sites · SLA&nbsp;99,9&nbsp;% · SOC&nbsp;2 Type&nbsp;I/II · PHIPA · PIPEDA Ontario · documentation gouv. (SEAO/MCN).
</p>
</div>
{% from 'macros/button.html' import button %}
{{ button('Demander une soumission', href='/contact?pro-plus=1', variant='primary', size='md') }}
</div>
</div>

View File

@@ -55,7 +55,7 @@
</div>
<h3 class="text-lg font-bold mb-2 text-brand-navy">Bureau</h3>
<p class="text-sm text-brand-navy/80 mb-4 leading-relaxed flex-grow">
Sur rendez-vous uniquement. Visites en personne pour démonstrations on-premise et déploiements DictIA 16 corporatifs.
Sur rendez-vous uniquement. Visites en personne pour démonstrations DictIA&nbsp;LOCAL et déploiements Cloud&nbsp;PRO corporatifs.
</p>
<address class="not-italic text-sm text-brand-navy/80 leading-relaxed">
77&nbsp;ch. de la Seigneurie<br>

View File

@@ -44,7 +44,7 @@
.orb-float-a { animation: orb-float 8s ease-in-out infinite; }
.orb-float-b { animation: orb-float 11s ease-in-out infinite reverse; }
/* DictIA Cloud pulse glow */
/* Cloud PRO pulse glow (recommended tier) */
@keyframes card-pulse-glow {
0%, 100% { box-shadow: 0 4px 20px rgba(37,99,235, 0.28); }
50% { box-shadow: 0 12px 40px rgba(37,99,235, 0.5); }
@@ -197,7 +197,7 @@
'title': 'Résumés &amp; points d\'action',
'desc': 'Résumé exécutif, décisions clés et liste d\'actions générés en français. Format avocat, notaire, CPA ou médecin selon votre profil. Récupérez ~2 h/jour de rédaction.',
'icon': icon_document,
'chips': ['Mistral 7B local (DictIA 16+)', 'Templates pro', 'Décisions + actions', '2&nbsp;h/jour récupérées']
'chips': ['Mistral Nemo 12B (Cloud) · Mistral 7B (Local)', 'Templates pro', 'Décisions + actions', '2&nbsp;h/jour récupérées']
},
{
'n': '04',
@@ -336,53 +336,65 @@
<div class="text-center max-w-3xl mx-auto mb-14">
<p class="eyebrow grad-text mb-4">ARCHITECTURE &amp; INFRASTRUCTURE</p>
<h2 id="architecture-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black">
<span class="ani-underline" data-ani-underline>Trois formules selon votre cabinet.</span>
<span class="ani-underline" data-ani-underline>Quatre formules selon votre organisation.</span>
</h2>
<p class="text-lg text-white/70 mt-6">
DictIA 8 et 16 fonctionnent localement chez vous. DictIA Cloud tourne sur GPU NVIDIA L4 dédié à OVH Beauharnois (Québec). Toutes les formules incluent volume audio illimité, zéro frais par utilisateur.
Cloud BASIC, ESSENTIEL et PRO tournent sur GPU NVIDIA L4 à OVH Beauharnois (Québec). DictIA LOCAL fonctionne 100&nbsp;% hors-ligne chez vous. Toutes les formules incluent aucune limite utilisateurs.
</p>
</div>
{%- set tiers = [
{
'name': 'DictIA 8',
'tagline': 'Petit cabinet, mode local',
'gpu': 'RTX 8&nbsp;Go',
'users': '2 à 5 utilisateurs',
'setup': '3&nbsp;450&nbsp;$',
'monthly': '173&nbsp;$',
'host': 'Local chez vous',
'llm': 'Mistral 7B non inclus',
'recommended': False,
'features': ['Transcription WhisperX FR-CA', 'Diarisation 8 locuteurs', 'Exports DOCX, PDF, SRT, VTT, TXT, JSON, MD', 'Synchronisation audio-texte', 'Admissible achat direct gouv. (≤ 34&nbsp;700&nbsp;$)']
},
{
'name': 'DictIA 16',
'tagline': 'Cabinet moyen, IA locale complète',
'gpu': 'RTX 16&nbsp;Go',
'users': '5 à 15 utilisateurs',
'setup': '5&nbsp;750&nbsp;$',
'monthly': '201&nbsp;$',
'host': 'Local chez vous',
'llm': 'Mistral 7B inclus',
'recommended': False,
'features': ['Tout DictIA 8', 'Résumés Mistral 7B local', 'Chat Q&amp;R sur enregistrement', 'Recherche sémantique', 'Templates avocat · notaire · CPA · médecin']
},
{
'name': 'DictIA Cloud',
'tagline': 'Multi-sites, infra gérée',
'gpu': 'NVIDIA L4 dédié',
'users': 'Utilisateurs illimités',
'name': 'Cloud BASIC',
'tagline': 'Solopreneur · petite équipe',
'gpu': 'NVIDIA L4 partagé',
'users': 'Aucune limite',
'setup': '—',
'monthly': '369&nbsp;$',
'monthly': '189&nbsp;$',
'host': 'OVH Beauharnois (QC)',
'llm': 'Mistral 7B inclus',
'llm': 'Mistral Nemo 12B',
'recommended': False,
'features': ['Transcription WhisperX Large-v3', 'Diarisation pyannote', '~165&nbsp;h audio/mois · 100&nbsp;Go', 'Exports DOCX, PDF, SRT, VTT, TXT, JSON, MD', 'Self-service · 0&nbsp;$ d&rsquo;installation']
},
{
'name': 'Cloud ESSENTIEL',
'tagline': 'Cabinet en croissance',
'gpu': 'L4 partagé étendu',
'users': 'Aucune limite',
'setup': '—',
'monthly': '349&nbsp;$',
'host': 'OVH Beauharnois (QC)',
'llm': 'Mistral Nemo 12B',
'recommended': False,
'features': ['Tout Cloud BASIC', '~330&nbsp;h audio/mois · 200&nbsp;Go', 'Onboarding assisté', 'Templates métier (avocat · notaire · CPA · médecin)']
},
{
'name': 'Cloud PRO',
'tagline': 'Usage intensif multi-postes',
'gpu': 'NVIDIA L4 dédié priorité',
'users': 'Aucune limite',
'setup': '485&nbsp;$',
'monthly': '549&nbsp;$',
'host': 'OVH Beauharnois (QC)',
'llm': 'Mistral Nemo 12B',
'recommended': True,
'features': ['Tout DictIA 16', 'Aucune infrastructure à gérer', 'Mises à jour automatiques', 'Hébergé au Québec (OVH)', 'Multi-sites et télétravail']
'features': ['Tout Cloud ESSENTIEL', '~660&nbsp;h audio/mois · 500&nbsp;Go', 'GPU dédié priorité (latence garantie)', 'Onboarding assisté inclus (485&nbsp;$ unique)', 'Multi-sites et télétravail']
},
{
'name': 'DictIA LOCAL',
'tagline': '100&nbsp;% hors-ligne · chez vous',
'gpu': 'RTX 5070&nbsp;Ti 16&nbsp;Go',
'users': 'Aucune limite',
'setup': '5&nbsp;998&nbsp;$',
'monthly': '500&nbsp;$/an dès An&nbsp;2',
'host': 'Chez le client',
'llm': 'Mistral 7B local',
'recommended': False,
'features': ['Tout Cloud PRO en mode local', '~1&nbsp;100&nbsp;h audio/mois · 2&nbsp;To SSD', 'GPU local dédié', 'Données jamais sortantes', 'Admissible achat direct gouv. (≤ 34&nbsp;700&nbsp;$)']
}
] -%}
<div class="grid md:grid-cols-3 gap-6 items-stretch">
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{% for tier in tiers %}
<article class="ani-fade {% if tier.recommended %}card-pulse-glow grad-bg p-[1.5px] rounded{% endif %} relative h-full"
style="--delay: {{ loop.index0 * 100 }}ms;"
@@ -395,22 +407,22 @@
{% endif %}
<div class="bg-brand-navy2 p-6 rounded border border-white/[0.08] h-full flex flex-col">
<header class="mb-4 pb-4 border-b border-white/[0.08]">
<h3 class="text-xl font-black mb-1 text-white">{{ tier.name }}</h3>
<p class="text-xs uppercase tracking-wider text-white/60">{{ tier.tagline }}</p>
<h3 class="text-xl font-black mb-1 text-white">{{ tier.name | safe }}</h3>
<p class="text-xs uppercase tracking-wider text-white/60">{{ tier.tagline | safe }}</p>
</header>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 mb-5 text-xs">
<dt class="text-white/60">GPU</dt><dd class="font-mono text-white">{{ tier.gpu | safe }}</dd>
<dt class="text-white/60">Utilisateurs</dt><dd class="text-white">{{ tier.users }}</dd>
<dt class="text-white/60">Hébergement</dt><dd class="text-white">{{ tier.host }}</dd>
<dt class="text-white/60">LLM résumés</dt><dd class="text-white">{{ tier.llm }}</dd>
<dt class="text-white/60">Utilisateurs</dt><dd class="text-white">{{ tier.users | safe }}</dd>
<dt class="text-white/60">Hébergement</dt><dd class="text-white">{{ tier.host | safe }}</dd>
<dt class="text-white/60">LLM résumés</dt><dd class="text-white">{{ tier.llm | safe }}</dd>
</dl>
<div class="mb-5 pb-5 border-b border-white/[0.08]">
{% if tier.setup != '—' %}
<div class="text-3xl font-black grad-text leading-none">{{ tier.setup | safe }}</div>
<div class="text-xs text-white/60 mt-1">setup unique + {{ tier.monthly | safe }}&nbsp;/ mois</div>
<div class="text-xs text-white/60 mt-1">{% if tier.name == 'DictIA LOCAL' %}An&nbsp;1 · puis {{ tier.monthly | safe }}{% else %}setup unique + {{ tier.monthly | safe }}&nbsp;/ mois{% endif %}</div>
{% else %}
<div class="text-3xl font-black grad-text leading-none">{{ tier.monthly | safe }}</div>
<div class="text-xs text-white/60 mt-1">par mois · sans frais de setup</div>
<div class="text-xs text-white/60 mt-1">par mois · sans frais d&rsquo;installation</div>
{% endif %}
</div>
<ul class="space-y-2 mb-6 flex-grow text-sm" role="list">
@@ -428,16 +440,16 @@
{% endfor %}
</div>
{# Bloc inclus dans setup local #}
{# Bloc inclus dans le forfait DictIA LOCAL #}
<div class="mt-12 max-w-4xl mx-auto bg-white/[0.05] backdrop-blur-sm p-6 rounded border border-white/[0.08]">
<p class="eyebrow grad-text mb-3">INCLUS DANS LE SETUP LOCAL (DICTIA 8 &amp; 16)</p>
<p class="eyebrow grad-text mb-3">INCLUS DANS LE FORFAIT DICTIA LOCAL (5&nbsp;998&nbsp;$ AN&nbsp;1)</p>
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 text-sm" role="list">
{% for inc in [
'Fourniture du PC + GPU',
'PC + GPU RTX 5070&nbsp;Ti + 2&nbsp;To SSD',
'Configuration complète',
'Installation sur site',
'Formation équipe (23&nbsp;h)',
'Support démarrage 30&nbsp;jours'
'1<sup>re</sup> année logiciel + support'
] %}
<li class="flex items-start gap-2 text-white/80">
<span class="text-brand-b3 mt-0.5" aria-hidden="true">{{ icon_check | safe }}</span>
@@ -448,7 +460,7 @@
</div>
<p class="text-xs text-white/60 text-center mt-6 max-w-2xl mx-auto">
DictIA 8 est admissible à l'achat direct gouvernemental sans appel d'offres (seuil 34&nbsp;700&nbsp;$ — Règlement sur les contrats d'approvisionnement, art.&nbsp;15).
DictIA LOCAL est admissible à l'achat direct gouvernemental sans appel d'offres (seuil 34&nbsp;700&nbsp;$ — Règlement sur les contrats d'approvisionnement, art.&nbsp;15). Pour &gt;&nbsp;660&nbsp;h audio/mois ou SLA&nbsp;99,9&nbsp;%, demandez une <a href="/contact?pro-plus=1" class="grad-text underline">soumission Pro+</a>.
</p>
</div>
</section>

View File

@@ -244,7 +244,7 @@
{# H2 (canonique : phrase clé en cyan/grad) #}
<p class="text-xl md:text-2xl lg:text-3xl font-black mb-6 leading-snug grad-text animate-tc-fade-in-up" style="animation-delay: 280ms; animation-fill-mode: backwards;">
Transcription IA locale en 2&nbsp;minutes — conforme Barreau, CPA Québec et ChAD.
Transcription IA locale en 2&nbsp;minutes — Audio → Texte · Résumés IA · Conforme Loi&nbsp;25 &amp; ordres professionnels
</p>
{# Sub canonique #}
@@ -1044,8 +1044,8 @@
</div>
<p class="text-center text-[10px] mb-3.5 text-brand-b1/65">Zéro frais caché · Du jamais vu</p>
<div class="flex items-baseline gap-2">
{# Round 5 — big number en grad-text (blue->cyan->fuchsia) #}
<span class="font-black leading-none text-5xl grad-text">173</span>
{# Round 5 — big number en grad-text (blue->cyan->fuchsia) — Cloud BASIC v7.0 #}
<span class="font-black leading-none text-5xl grad-text">189</span>
<span class="text-sm text-brand-navy/55 mb-0.5 font-semibold">$ / mois</span>
</div>
<div class="flex items-center gap-1.5 mt-2">
@@ -1367,7 +1367,7 @@
<div class="absolute inset-0 pointer-events-none" :style="`opacity: ${orderOp}`" aria-hidden="true">
{% for sol in [
{'text': '~2 min pour 1h d\'audio', 'x': 20, 'y': 12},
{'text': 'Dès 173&nbsp;$/mois — illimité', 'x': 64, 'y': 10},
{'text': 'Dès 189&nbsp;$/mois — illimité', 'x': 64, 'y': 10},
{'text': '90&nbsp;%+ d\'économies', 'x': 74, 'y': 38},
{'text': '100&nbsp;% Confidentiel — Hébergé au Qc', 'x': 18, 'y': 40}
] %}
@@ -1961,7 +1961,7 @@
x-text="payback === null ? 'Payable dès la première année' : (payback < 1 ? 'Payback : moins d\'un mois' : 'Payback : ' + Math.round(payback) + ' mois')"></p>
</div>
<p class="text-xs text-brand-navy/70 mt-6 text-center">
Hypothèses&nbsp;: 80&nbsp;% du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5&nbsp;750&nbsp;$ + 201&nbsp;$/mois). Estimation à titre indicatif.
Hypothèses&nbsp;: 80&nbsp;% du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à Cloud&nbsp;ESSENTIEL (349&nbsp;$/mois × 12 = 4&nbsp;188&nbsp;$/an). Estimation à titre indicatif.
</p>
</div>
</div>

View File

@@ -1,7 +1,7 @@
{% extends 'marketing/base.html' %}
{% block title %}Tarifs DictIA — 3 forfaits transparents en CAD (369 $/mois Cloud, à partir de 173 $/mois on-premise){% endblock %}
{% block description %}Tarifs DictIA en CAD : DictIA 8 (PME), DictIA 16 (cabinets juridiques) et DictIA Cloud (organismes). Volume illimité, zéro frais par utilisateur, taxes en sus.{% endblock %}
{% block title %}Tarifs DictIA — 4 forfaits transparents en CAD (Cloud Basic 189 $/mo · Essentiel 349 $ · Pro 549 $ · DictIA Local 5 998 $){% endblock %}
{% block description %}Tarifs DictIA en CAD : Cloud Basic (189 $/mo), Cloud Essentiel (349 $/mo), Cloud Pro (549 $/mo + 485 $ onboarding) et DictIA Local (5 998 $ An 1 puis 500 $/an). Aucune limite utilisateurs, taxes en sus.{% endblock %}
{% block content %}
@@ -10,18 +10,18 @@
<div class="max-w-[820px] mx-auto px-6 text-center">
<p class="eyebrow grad-text mb-4">TARIFS</p>
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
Trois forfaits&nbsp;: <span class="grad-text">choisissez votre infrastructure</span>.
Quatre forfaits&nbsp;: <span class="grad-text">choisissez votre infrastructure</span>.
</h1>
<p class="text-lg text-white/80">
Volume illimité, zéro frais par utilisateur. Tarifs en CAD, taxes en sus (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%).
3 Cloud souverains au Québec + 1 100&nbsp;% local hors-ligne. Aucune limite utilisateurs, tarifs en CAD, taxes en sus (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%).
</p>
</div>
</section>
{# ===== 3 PRICING TIERS ===== #}
{# ===== 4 PRICING TIERS + Pro+ ===== #}
<section class="bg-brand-bg py-20" aria-labelledby="forfaits-title">
<div class="max-w-[1200px] mx-auto px-6">
<h2 id="forfaits-title" class="sr-only">Trois forfaits DictIA</h2>
<h2 id="forfaits-title" class="sr-only">Quatre forfaits DictIA + Pro+ sur soumission</h2>
{% include 'marketing/_partials/_pricing_tiers.html' %}
</div>
</section>
@@ -37,33 +37,37 @@
</div>
<div class="overflow-x-auto rounded border border-brand-border">
<table class="w-full min-w-[720px] text-sm">
<caption class="sr-only">Comparaison détaillée des 3 forfaits DictIA sur 8 caractéristiques</caption>
<table class="w-full min-w-[820px] text-sm">
<caption class="sr-only">Comparaison détaillée des 4 forfaits DictIA sur 9 caractéristiques techniques et opérationnelles</caption>
<thead class="bg-brand-bg">
<tr>
<th scope="col" class="text-left p-4 font-bold text-brand-navy">Caractéristique</th>
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA 8</th>
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA 16</th>
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA Cloud</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud BASIC</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud ESSENTIEL</th>
<th scope="col" class="p-4 font-bold text-brand-navy">Cloud PRO</th>
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA LOCAL</th>
</tr>
</thead>
{%- set svg_check = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 inline-block text-brand-b3" aria-label="Inclus" role="img"><path d="M5 13l4 4L19 7"/></svg>' -%}
<tbody class="divide-y divide-brand-border">
{% for row in [
{'name': 'Hébergement', 'd8': 'Sur place (vos murs)', 'd16': 'Sur place (vos murs)', 'cloud': 'OVH Beauharnois (QC)'},
{'name': 'GPU', 'd8': '8&nbsp;Go RTX', 'd16': '16&nbsp;Go RTX', 'cloud': 'Mutualisé (géré)'},
{'name': 'Volume audio', 'd8': 'Illimité', 'd16': 'Illimité', 'cloud': 'Illimité'},
{'name': 'Utilisateurs', 'd8': 'Illimité', 'd16': 'Illimité', 'cloud': 'Illimité'},
{'name': 'Diarisation', 'd8': '8 locuteurs', 'd16': '8 locuteurs', 'cloud': '8 locuteurs'},
{'name': 'Résumés Mistral 7B local', 'd8': '—', 'd16': svg_check, 'cloud': svg_check ~ '<span class="ml-1">(mutualisé)</span>'},
{'name': 'Q&amp;R sur enregistrement', 'd8': '—', 'd16': svg_check, 'cloud': svg_check},
{'name': 'Délai de mise en service', 'd8': '~2&nbsp;semaines', 'd16': '~2&nbsp;semaines', 'cloud': '48&nbsp;h'}
{'name': 'Hébergement', 'basic': 'OVH Beauharnois (QC)', 'ess': 'OVH Beauharnois (QC)', 'pro': 'OVH Beauharnois (QC)', 'local': 'Chez le client (100&nbsp;% hors-ligne)'},
{'name': 'GPU', 'basic': 'NVIDIA L4 partagé', 'ess': 'L4 partagé étendu', 'pro': 'L4 dédié priorité', 'local': 'RTX 5070&nbsp;Ti 16&nbsp;Go'},
{'name': 'Capacité audio', 'basic': '~165&nbsp;h/mois', 'ess': '~330&nbsp;h/mois', 'pro': '~660&nbsp;h/mois', 'local': '~1&nbsp;100&nbsp;h/mois'},
{'name': 'Stockage', 'basic': '100&nbsp;Go', 'ess': '200&nbsp;Go', 'pro': '500&nbsp;Go', 'local': '2&nbsp;To SSD'},
{'name': 'Utilisateurs', 'basic': 'Aucune limite', 'ess': 'Aucune limite', 'pro': 'Aucune limite', 'local': 'Aucune limite'},
{'name': 'Diarisation pyannote', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check},
{'name': 'Résumés IA + Points d&rsquo;action','basic': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'ess': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'pro': svg_check ~ '<span class="ml-1 text-xs">(Mistral Nemo 12B)</span>', 'local': svg_check ~ '<span class="ml-1 text-xs">(Mistral 7B local)</span>'},
{'name': 'Conformité Loi&nbsp;25', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check ~ '<span class="ml-1 text-xs">+ 100&nbsp;% hors-ligne</span>'},
{'name': 'SLA', 'basic': '99,5&nbsp;%', 'ess': '99,5&nbsp;%', 'pro': '99,5&nbsp;%', 'local': '—&nbsp;(resp. client)'},
{'name': 'Délai de mise en service', 'basic': '48&nbsp;h', 'ess': '48&nbsp;h', 'pro': '48&nbsp;h + onboarding', 'local': '~2&nbsp;semaines'}
] %}
<tr>
<th scope="row" class="text-left p-4 font-semibold text-brand-navy/80">{{ row.name | safe }}</th>
<td class="p-4 text-center text-brand-navy/80">{{ row.d8 | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.d16 | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.cloud | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.basic | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.ess | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.pro | safe }}</td>
<td class="p-4 text-center text-brand-navy/80">{{ row.local | safe }}</td>
</tr>
{% endfor %}
</tbody>
@@ -71,7 +75,7 @@
</div>
<p class="text-xs text-brand-navy/70 mt-6 text-center max-w-3xl mx-auto">
Caractéristiques au 2026-04-27. Pour un devis personnalisé ou des besoins multi-sites, écrivez à <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
Caractéristiques au 2026-04-27. Pour un volume &gt;&nbsp;660&nbsp;h audio/mois, multi-sites ou SLA&nbsp;99,9&nbsp;%, demandez une <a href="/contact?pro-plus=1" class="grad-text font-semibold hover:underline">soumission Pro+</a>. Questions&nbsp;: <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
</p>
</div>
</section>
@@ -86,11 +90,13 @@
<div class="divide-y divide-brand-border border-y border-brand-border">
{% for item in [
{'q': 'Y a-t-il des frais cachés?', 'a': 'Non. Les tarifs affichés couvrent l\'utilisation illimitée (volume audio, utilisateurs, exports). Les seules variables sont&nbsp;: les taxes (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%) et, pour DictIA on-premise, le matériel GPU si vous ne l\'avez pas déjà. Aucun frais par minute, par mot, par locuteur.'},
{'q': 'Puis-je passer d\'un forfait à un autre?', 'a': 'Oui, en tout temps. Les passages de DictIA Cloud vers on-premise et inversement sont supportés. Les données peuvent être migrées sur demande, sans frais. Détails dans nos <a href="/legal/conditions" class="grad-text underline">conditions d\'utilisation</a>.'},
{'q': 'Le tarif on-premise inclut-il le matériel GPU?', 'a': 'Le tarif setup (3&nbsp;450&nbsp;$ pour DictIA 8 ou 5&nbsp;750&nbsp;$ pour DictIA 16) inclut l\'installation logicielle complète, la configuration sécurité, la formation et 90 jours de support prioritaire. Le matériel GPU n\'est pas inclus&nbsp;; nous fournissons une liste de cartes RTX recommandées (RTX 4060 8&nbsp;Go pour DictIA 8, RTX 4080/5080 16&nbsp;Go pour DictIA 16) et pouvons faire l\'achat pour vous moyennant marge transparente.'},
{'q': 'Y a-t-il des frais cachés?', 'a': 'Non. Les tarifs affichés couvrent l\'utilisation de la capacité indiquée (audio mensuel, stockage) sans frais par utilisateur. Les seules variables sont&nbsp;: les taxes (TPS&nbsp;5&nbsp;% + TVQ&nbsp;9,975&nbsp;%) et, pour DictIA Local, le matériel inclus dans le 5&nbsp;998&nbsp;$ An&nbsp;1. Aucun frais par minute, par mot, par locuteur.'},
{'q': 'Puis-je passer d\'un forfait à un autre?', 'a': 'Oui, en tout temps. Les passages entre Cloud Basic, Essentiel et Pro sont supportés (prorata Stripe). Migration Cloud → DictIA Local (et inversement) sur demande, sans frais. Détails dans nos <a href="/legal/conditions" class="grad-text underline">conditions d\'utilisation</a>.'},
{'q': 'Que comprend le 5&nbsp;998&nbsp;$ de DictIA Local?', 'a': 'Le forfait DictIA Local An&nbsp;1 inclut&nbsp;: PC + GPU RTX 5070&nbsp;Ti 16&nbsp;Go + 2&nbsp;To SSD, installation sur site, configuration sécurité, formation équipe, et la première année de licence logicielle. Dès l\'An&nbsp;2, seul le renouvellement annuel de 500&nbsp;$/an (mises à jour + support) est facturé.'},
{'q': 'Comment fonctionne le 485&nbsp;$ d\'onboarding Cloud Pro?', 'a': 'Le forfait Cloud Pro inclut un onboarding assisté unique (485&nbsp;$) couvrant&nbsp;: configuration des comptes, importation des hotwords métier, formation équipe (1&nbsp;h visioconférence), tests de charge initiaux. Cloud Basic et Cloud Essentiel sont en self-service (aucun frais d\'installation).'},
{'q': 'Comment fonctionne la facturation TPS/TVQ?', 'a': 'DictIA Inc. est inscrite TPS et TVQ. Les factures détaillent les taxes selon votre province de facturation. Pour les organismes exemptés (organismes publics, etc.), envoyez votre attestation à info@dictia.ca avant l\'inscription.'},
{'q': 'Existe-t-il un tarif annuel ou pluriannuel?', 'a': 'Disponible sur demande pour les engagements 12 ou 24 mois (remise typique de 10 à 15&nbsp;%). Écrivez à <a href="mailto:info@dictia.ca" class="grad-text underline">info@dictia.ca</a> pour un devis.'}
{'q': 'Existe-t-il un tarif annuel sur les forfaits Cloud?', 'a': 'Oui. Les paiements annuels sur Cloud Basic, Essentiel et Pro bénéficient d\'une remise de 15&nbsp;% (équivalent ~10&nbsp;mois payés au lieu de 12). Sélectionnable au moment du paiement Stripe.'},
{'q': 'Quand demander une soumission Pro+?', 'a': 'Pro+ s\'adresse aux organisations ayant besoin de&nbsp;: &gt;&nbsp;660&nbsp;h audio/mois, &gt;&nbsp;500&nbsp;Go de stockage, 7+ utilisateurs simultanés intensifs, multi-sites, SLA renforcé 99,9&nbsp;%, audits SOC&nbsp;2 Type&nbsp;I/II, conformité PHIPA / PIPEDA Ontario, ou documentation gouvernementale (SEAO/MCN). Demandez une <a href="/contact?pro-plus=1" class="grad-text underline">soumission personnalisée</a>.'}
] %}
<div x-data="{ open: false }" class="py-2">
<h3>

View File

@@ -326,54 +326,66 @@ def test_pricing_section_present():
assert 'TPS' in body and 'TVQ' in body, "Missing tax disclaimer (TPS/TVQ)"
def test_pricing_3_tiers_with_canonical_amounts():
"""Pricing has 3 tiers: DictIA 8 (3450/173), DictIA 16 (5750/201), DictIA Cloud (0/369)."""
def test_pricing_4_tiers_with_canonical_amounts_v7():
"""Pricing v7.0 — 4 tiers + Pro+ chip:
Cloud BASIC (189 $/mo), Cloud ESSENTIEL (349 $/mo),
Cloud PRO (549 $/mo + 485 $ onboarding) RECOMMANDÉ,
DictIA LOCAL (5 998 $ An 1 + 500 $/an dès An 2).
"""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# Names
for name in ['DictIA 8', 'DictIA 16', 'DictIA Cloud']:
assert name in body, f"Missing pricing tier: {name}"
# Canonical prices with NBSP per OQLF
assert '3&nbsp;450&nbsp;$' in body, "Missing DictIA 8 setup price"
assert '173&nbsp;$' in body, "Missing DictIA 8 monthly price"
assert '5&nbsp;750&nbsp;$' in body, "Missing DictIA 16 setup price"
assert '201&nbsp;$' in body, "Missing DictIA 16 monthly price"
assert '369&nbsp;$' in body, "Missing DictIA Cloud monthly price (canonical 369$)"
# 4 forfait names
for name in ['Cloud BASIC', 'Cloud ESSENTIEL', 'Cloud PRO', 'DictIA LOCAL']:
assert name in body, f"Missing v7.0 pricing tier: {name}"
# Canonical prices with NBSP per OQLF (formatted by macro)
assert '189&nbsp;$' in body, "Missing Cloud BASIC monthly price (189 $)"
assert '349&nbsp;$' in body, "Missing Cloud ESSENTIEL monthly price (349 $)"
assert '549&nbsp;$' in body, "Missing Cloud PRO monthly price (549 $)"
assert '485&nbsp;$' in body, "Missing Cloud PRO setup/onboarding price (485 $)"
assert '5&nbsp;998&nbsp;$' in body, "Missing DictIA LOCAL An 1 price (5 998 $)"
assert '500&nbsp;$/an' in body, "Missing DictIA LOCAL renewal mention (500 $/an)"
# Pro+ soumission chip
assert 'Pro+' in body, "Missing Pro+ soumission chip"
assert '/contact?pro-plus=1' in body, "Missing Pro+ CTA link"
def test_pricing_recommended_tier_is_dictia_16():
"""DictIA 16 is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame)."""
def test_pricing_recommended_tier_is_cloud_pro():
"""Cloud PRO is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
assert 'RECOMMAND' in body, "Missing RECOMMANDÉ badge"
# The recommended tier wraps in grad-bg p-[1.5px] rounded FlexiHub style (V3 brutalist 4px card frame)
assert 'grad-bg p-[1.5px] rounded"' in body or 'grad-bg p-[1.5px] rounded ' in body, \
"Missing FlexiHub gradient frame on recommended tier (rounded 4px)"
assert 'grad-bg p-[1.5px] rounded' in body, \
"Missing FlexiHub gradient frame on recommended tier"
def test_pricing_cta_uses_reserver_pre_launch_wording():
"""CTAs say 'Réserver' not 'Choisir' — pre-launch LPC art. 219 hygiene."""
def test_pricing_cta_labels_v7():
"""CTAs reflect v7.0 forfait choice (Démarrer en Cloud / Choisir Essentiel / Commander Pro / Configurer DictIA Local)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']:
for slug in ['cloud-basic', 'cloud-essentiel', 'cloud-pro', 'dictia-local']:
assert f'href="/checkout/{slug}"' in body, f"Missing checkout link for {slug}"
assert 'Réserver DictIA 8' in body or 'R&eacute;server DictIA 8' in body, "CTA must use 'Réserver' wording (pre-launch)"
# CTA labels match the macro callers in _pricing_tiers.html
assert 'Démarrer en Cloud' in body or 'D&eacute;marrer en Cloud' in body
assert 'Choisir Essentiel' in body
assert 'Commander Pro' in body
assert 'Configurer DictIA Local' in body
def test_pricing_features_use_safe_filter_no_double_escape():
"""Pricing card features piped through | safe — '&nbsp;' must render single-escaped, not double."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# GPU sizes use NBSP
assert 'GPU 8&nbsp;Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
assert 'GPU 16&nbsp;Go RTX' in body, "GPU 16 Go feature missing or NBSP double-escaped"
# Q&R card must use French Q&R, not English Q&A
assert 'Q&amp;R' in body, "DictIA 16 must mention Q&R (French), not Q&A (English)"
assert 'Q&A' not in body, "Must use French Q&R consistently — no English Q&A"
# Capacity chips use NBSP
assert '~165&nbsp;h audio/mois' in body, "Missing Cloud BASIC capacity chip"
assert '100&nbsp;Go' in body, "Missing Cloud BASIC storage chip"
assert '~660&nbsp;h audio/mois' in body, "Missing Cloud PRO capacity chip"
assert '500&nbsp;Go' in body, "Missing Cloud PRO storage chip"
assert '2&nbsp;To SSD' in body, "Missing DictIA LOCAL storage chip"
# WhisperX precision claim w/ NBSP
assert 'WhisperX Large-v3' in body, "Missing WhisperX Large-v3 mention"
# Loi 25 with NBSP
assert 'Conforme Loi&nbsp;25' in body, "Conforme Loi 25 must use NBSP"
# SLA must be hedged ('visé') not absolute claim
assert 'SLA visé 99,9' in body, "SLA must be hedged 'visé' (pre-launch LPC art. 219 hygiene)"
assert 'Loi&nbsp;25' in body, "Loi 25 must use NBSP"
# Negative: NO double-escape
assert '&amp;nbsp;' not in body, "NBSP must not be double-escaped — | safe missing on pricing macro?"
@@ -450,8 +462,8 @@ def test_pricing_cta_url_no_double_slash():
"""pricing_card uses cta_url.rstrip('/') so href never has '//' (regression guard)."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# All 3 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/dictia-X
for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']:
# All 4 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/<slug>
for slug in ['cloud-basic', 'cloud-essentiel', 'cloud-pro', 'dictia-local']:
assert f'href="/checkout/{slug}"' in body, f"Missing single-slash href for {slug}"
assert f'href="/checkout//{slug}"' not in body, f"Double-slash regression for {slug}"
@@ -788,7 +800,7 @@ def test_round2_cycle_section_present():
assert 'Retranscription humaine' in body
assert 'IA cloud américaine' in body
assert 'NON CONFORME' in body
assert '315' in body and '173' in body, "Canonical Cycle pricing must appear"
assert '315' in body and '189' in body, "Canonical Cycle pricing must appear (315 humain vs 189 Cloud BASIC v7.0)"
assert 'Loi 25 conforme' in body
assert '100&nbsp;% hébergé au Québec' in body or '100 % hébergé au Québec' in body
# Phase animation hooks
@@ -811,9 +823,9 @@ def test_round2_wave_section_present():
# Canonical pain labels
assert '4 à 6h pour transcrire 1h' in body
assert 'Délais de 48h à 5 jours' in body
# Canonical solution labels (NBSP-aware)
# Canonical solution labels (NBSP-aware) — v7.0 Cloud BASIC entry price
assert '~2 min pour 1h d' in body
assert '173&nbsp;$/mois' in body or '173 $/mois' in body
assert '189&nbsp;$/mois' in body or '189 $/mois' in body
# Alpine state for interactive slider
assert 'onMove($event)' in body
assert 'isMobile' in body
@@ -989,8 +1001,8 @@ def test_round2_oqlf_nbsp_in_new_sections():
body = client.get('/').data.decode('utf-8')
# Cycle section savings
assert '3&nbsp;924&nbsp;$' in body or '3 924 $' in body
# Wave solution card pricing
assert '173&nbsp;$/mois' in body or 'Dès 173' in body
# Wave solution card pricing — v7.0 Cloud BASIC entry price
assert '189&nbsp;$/mois' in body or 'Dès 189' in body
# Cadre — Loi 25 fine
assert '25 M$' in body or '25&nbsp;M$' in body

View File

@@ -33,41 +33,51 @@ def test_tarifs_has_h1_with_anchor():
assert '<h1' in body and 'choisissez votre infrastructure' in body
def test_tarifs_renders_3_pricing_cards():
def test_tarifs_renders_4_pricing_cards_v7():
"""Tarifs page renders the v7.0 4 forfaits + Pro+ chip."""
client = app.test_client()
body = client.get('/tarifs').data.decode('utf-8')
for tier in ['DictIA 8', 'DictIA 16', 'DictIA Cloud']:
for tier in ['Cloud BASIC', 'Cloud ESSENTIEL', 'Cloud PRO', 'DictIA LOCAL']:
assert tier in body
# Canonical NBSP prices
assert '3&nbsp;450&nbsp;$' in body
assert '5&nbsp;750&nbsp;$' in body
assert '369&nbsp;$' in body
assert 'href="/checkout/dictia-8"' in body
assert 'href="/checkout/dictia-16"' in body
assert 'href="/checkout/dictia-cloud"' in body
# Canonical NBSP prices (v7.0)
assert '189&nbsp;$' in body
assert '349&nbsp;$' in body
assert '549&nbsp;$' in body
assert '485&nbsp;$' in body # Cloud Pro onboarding
assert '5&nbsp;998&nbsp;$' in body # DictIA Local An 1
# Checkout slugs
assert 'href="/checkout/cloud-basic"' in body
assert 'href="/checkout/cloud-essentiel"' in body
assert 'href="/checkout/cloud-pro"' in body
assert 'href="/checkout/dictia-local"' in body
# Pro+ chip with /contact link
assert 'Pro+' in body
assert '/contact?pro-plus=1' in body
def test_tarifs_comparison_matrix_8_rows():
def test_tarifs_comparison_matrix_v7():
"""v7.0 comparison matrix has 4 columns + 10 rows."""
client = app.test_client()
body = client.get('/tarifs').data.decode('utf-8')
assert 'matrix-title' in body
assert '<caption class="sr-only">' in body
assert 'scope="col"' in body
assert 'scope="row"' in body
# 8 row keywords
for kw in ['Hébergement', 'GPU', 'Volume audio', 'Utilisateurs',
'Diarisation', 'Mistral 7B local', 'Q&amp;R', 'Délai']:
# v7.0 row keywords (matches the rows in tarifs.html)
for kw in ['Hébergement', 'GPU', 'Capacité audio', 'Stockage', 'Utilisateurs',
'Diarisation pyannote', 'Loi&nbsp;25', 'SLA', 'Délai']:
assert kw in body, f"Missing matrix row keyword: {kw}"
def test_tarifs_pricing_faq_5_questions():
def test_tarifs_pricing_faq_v7():
"""v7.0 tarifs FAQ has 7 questions (added DictIA Local + Cloud Pro onboarding + Pro+ explanations)."""
client = app.test_client()
body = client.get('/tarifs').data.decode('utf-8')
assert 'tarifs-faq-title' in body
for i in range(1, 6):
for i in range(1, 8):
assert f'tarifs-faq-panel-{i}' in body, f"Missing tarifs FAQ panel {i}"
# Alpine accordion bindings
assert body.count('x-data="{ open: false }"') >= 5
assert body.count('x-data="{ open: false }"') >= 7
# Each accordion button has focus-visible (WCAG 2.4.7/2.4.11)
assert 'focus-visible:outline-2' in body
@@ -150,8 +160,6 @@ def test_fonctionnalites_uses_oqlf_typography():
body = client.get('/fonctionnalites').data.decode('utf-8')
# NBSP entities
assert '95&nbsp;%+' in body, "WhisperX precision NBSP entity"
assert 'GPU 8&nbsp;Go RTX' not in body # Bento card calls don't use 8 Go RTX (that's pricing)
assert 'Q&amp;R' in body, "French Q&R (not Q&A)"
# No double-escape
assert '&amp;nbsp;' not in body

View File

@@ -29,9 +29,11 @@ from src.models.user import User # noqa: E402
_PRICE_ENV_VARS = (
'STRIPE_SECRET_KEY',
'STRIPE_DICTIA_8_SETUP', 'STRIPE_DICTIA_8_MONTHLY', 'STRIPE_DICTIA_8_YEARLY',
'STRIPE_DICTIA_16_SETUP', 'STRIPE_DICTIA_16_MONTHLY', 'STRIPE_DICTIA_16_YEARLY',
'STRIPE_DICTIA_CLOUD_MONTHLY', 'STRIPE_DICTIA_CLOUD_YEARLY',
# v7.0 — Cloud BASIC / ESSENTIEL (no setup) + Cloud PRO (setup) + DictIA LOCAL (setup + yearly renewal)
'STRIPE_CLOUD_BASIC_MONTHLY', 'STRIPE_CLOUD_BASIC_YEARLY',
'STRIPE_CLOUD_ESSENTIEL_MONTHLY', 'STRIPE_CLOUD_ESSENTIEL_YEARLY',
'STRIPE_CLOUD_PRO_SETUP', 'STRIPE_CLOUD_PRO_MONTHLY', 'STRIPE_CLOUD_PRO_YEARLY',
'STRIPE_DICTIA_LOCAL_SETUP', 'STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY',
)
@@ -99,10 +101,10 @@ def test_is_stripe_configured_when_env_unset():
def test_get_plan_returns_known_plan():
from src.billing.plans import get_plan, Plan
plan = get_plan('dictia-cloud')
plan = get_plan('cloud-basic')
assert plan is not None
assert isinstance(plan, Plan)
assert plan.slug == 'dictia-cloud'
assert plan.slug == 'cloud-basic'
assert plan.has_setup_fee is False
@@ -119,10 +121,10 @@ def test_get_plan_returns_none_for_unknown():
def test_plan_is_configured_when_env_set():
_clear_stripe_env()
try:
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cloud_m'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cloud_y'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_basic_m'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_basic_y'
from src.billing.plans import get_plan
assert get_plan('dictia-cloud').is_configured() is True
assert get_plan('cloud-basic').is_configured() is True
finally:
_clear_stripe_env()
@@ -131,21 +133,49 @@ def test_plan_is_not_configured_when_env_missing():
_clear_stripe_env()
try:
from src.billing.plans import get_plan
assert get_plan('dictia-cloud').is_configured() is False
assert get_plan('cloud-basic').is_configured() is False
finally:
_clear_stripe_env()
def test_hardware_plan_requires_setup_env():
def test_cloud_pro_requires_setup_env():
"""Cloud PRO needs monthly + yearly + setup (485 $ onboarding) Price IDs."""
_clear_stripe_env()
try:
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8_m'
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8_y'
# NO STRIPE_DICTIA_8_SETUP
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
# NO STRIPE_CLOUD_PRO_SETUP
from src.billing.plans import get_plan
assert get_plan('dictia-8').is_configured() is False
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_8_setup'
assert get_plan('dictia-8').is_configured() is True
assert get_plan('cloud-pro').is_configured() is False
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_pro_setup'
assert get_plan('cloud-pro').is_configured() is True
finally:
_clear_stripe_env()
def test_dictia_local_requires_setup_and_renewal():
"""DictIA LOCAL needs setup (An 1) + yearly_renewal (An 2+) Price IDs."""
_clear_stripe_env()
try:
os.environ['STRIPE_DICTIA_LOCAL_SETUP'] = 'price_local_setup'
from src.billing.plans import get_plan
assert get_plan('dictia-local').is_configured() is False
os.environ['STRIPE_DICTIA_LOCAL_RENEWAL_YEARLY'] = 'price_local_renewal'
assert get_plan('dictia-local').is_configured() is True
finally:
_clear_stripe_env()
def test_pro_plus_is_quote_only_never_configured():
"""Pro+ is a sentinel — no Stripe Price IDs, route redirects to /contact."""
_clear_stripe_env()
try:
from src.billing.plans import get_plan
plan = get_plan('pro-plus')
assert plan is not None
assert plan.is_quote_only is True
# Even with fake env vars, Pro+ never has Stripe Price IDs to set
assert plan.is_configured() is False
finally:
_clear_stripe_env()
@@ -203,29 +233,30 @@ def test_get_or_create_customer_reuses_existing():
# 10-13. create_checkout_session
# ----------------------------------------------------------------------
def test_create_checkout_session_includes_setup_for_hardware_plan():
def test_create_checkout_session_includes_setup_for_cloud_pro():
"""Cloud PRO has 2 line items: 485 $ onboarding setup + recurring monthly."""
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_setup'
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8m'
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8y'
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_setup'
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
db.create_all()
try:
user = _make_user(email='hwsetup@example.qc.ca', name='Bob')
user = _make_user(email='prosetup@example.qc.ca', name='Bob')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_x')
mock_sess.return_value = MagicMock(url='https://checkout.stripe.test/cs_x')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-8', period='monthly', user=user,
plan_slug='cloud-pro', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert len(kwargs['line_items']) == 2
assert kwargs['line_items'][0]['price'] == 'price_setup'
assert kwargs['line_items'][1]['price'] == 'price_8m'
assert kwargs['line_items'][1]['price'] == 'price_pro_m'
assert kwargs['mode'] == 'subscription'
assert kwargs['currency'] == 'cad'
assert kwargs['automatic_tax']['enabled'] is True
@@ -239,27 +270,28 @@ def test_create_checkout_session_includes_setup_for_hardware_plan():
_clear_stripe_env()
def test_create_checkout_session_no_setup_for_cloud_plan():
def test_create_checkout_session_no_setup_for_cloud_basic():
"""Cloud BASIC has no setup fee — single recurring line item."""
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
db.create_all()
try:
user = _make_user(email='cloudplan@example.qc.ca', name='Carol')
user = _make_user(email='basicplan@example.qc.ca', name='Carol')
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
mock_cust.return_value = MagicMock(id='cus_y')
mock_sess.return_value = MagicMock(url='https://x/cs_y')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
plan_slug='cloud-basic', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert len(kwargs['line_items']) == 1
assert kwargs['line_items'][0]['price'] == 'price_cm'
assert kwargs['line_items'][0]['price'] == 'price_bm'
finally:
db.session.rollback()
db.drop_all()
@@ -270,8 +302,8 @@ def test_create_checkout_session_uses_yearly_price_when_period_yearly():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
db.create_all()
try:
user = _make_user(email='yearly@example.qc.ca', name='Dan')
@@ -281,11 +313,11 @@ def test_create_checkout_session_uses_yearly_price_when_period_yearly():
mock_sess.return_value = MagicMock(url='https://x/cs_z')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='yearly', user=user,
plan_slug='cloud-basic', period='yearly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
assert kwargs['line_items'][0]['price'] == 'price_cy'
assert kwargs['line_items'][0]['price'] == 'price_by'
finally:
db.session.rollback()
db.drop_all()
@@ -296,8 +328,8 @@ def test_create_checkout_session_includes_metadata():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
db.create_all()
try:
user = _make_user(email='meta@example.qc.ca', name='Eve')
@@ -307,18 +339,18 @@ def test_create_checkout_session_includes_metadata():
mock_sess.return_value = MagicMock(url='https://x/cs_q')
from src.billing.stripe_client import create_checkout_session
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
plan_slug='cloud-basic', period='monthly', user=user,
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
)
kwargs = mock_sess.call_args.kwargs
meta = kwargs['metadata']
assert meta['dictia_user_id'] == str(user.id)
assert meta['dictia_plan_slug'] == 'dictia-cloud'
assert meta['dictia_plan_slug'] == 'cloud-basic'
assert meta['dictia_period'] == 'monthly'
# Subscription-level metadata too (used by webhook B-2.8)
sub_meta = kwargs['subscription_data']['metadata']
assert sub_meta['dictia_user_id'] == str(user.id)
assert sub_meta['dictia_plan_slug'] == 'dictia-cloud'
assert sub_meta['dictia_plan_slug'] == 'cloud-basic'
assert sub_meta['dictia_period'] == 'monthly'
finally:
db.session.rollback()
@@ -356,15 +388,15 @@ def test_create_checkout_session_raises_on_invalid_period():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='badperiod@example.qc.ca')
from src.billing.stripe_client import create_checkout_session
try:
create_checkout_session(
plan_slug='dictia-cloud', period='quarterly', user=user,
plan_slug='cloud-basic', period='quarterly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected ValueError')
@@ -388,7 +420,7 @@ def test_create_checkout_session_raises_when_stripe_not_configured():
)
try:
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
plan_slug='cloud-basic', period='monthly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected StripeNotConfiguredError')
@@ -404,7 +436,7 @@ def test_create_checkout_session_raises_when_plan_env_missing():
with app.app_context():
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
# NO price IDs for dictia-cloud
# NO price IDs for cloud-basic
db.create_all()
try:
user = _make_user(email='noprice@example.qc.ca')
@@ -413,7 +445,7 @@ def test_create_checkout_session_raises_when_plan_env_missing():
)
try:
create_checkout_session(
plan_slug='dictia-cloud', period='monthly', user=user,
plan_slug='cloud-basic', period='monthly', user=user,
success_url='https://x/s', cancel_url='https://x/c',
)
raise AssertionError('Expected StripeNotConfiguredError')
@@ -434,8 +466,8 @@ def test_checkout_route_redirects_to_stripe_url():
_disable_csrf()
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='rt-redir@example.qc.ca', name='Frank')
@@ -445,14 +477,14 @@ def test_checkout_route_redirects_to_stripe_url():
mock_create.return_value = MagicMock(
url='https://checkout.stripe.test/cs_redir'
)
resp = client.get('/checkout/dictia-cloud?period=monthly',
resp = client.get('/checkout/cloud-basic?period=monthly',
follow_redirects=False)
assert resp.status_code == 303
assert resp.headers['Location'] == 'https://checkout.stripe.test/cs_redir'
# Ensure routes called the helper with the right args
mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['plan_slug'] == 'dictia-cloud'
assert call_kwargs['plan_slug'] == 'cloud-basic'
assert call_kwargs['period'] == 'monthly'
finally:
db.session.rollback()
@@ -484,8 +516,8 @@ def test_checkout_route_normalizes_invalid_period_to_monthly():
_disable_csrf()
_clear_stripe_env()
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
db.create_all()
try:
user = _make_user(email='rt-period@example.qc.ca')
@@ -495,7 +527,7 @@ def test_checkout_route_normalizes_invalid_period_to_monthly():
mock_create.return_value = MagicMock(
url='https://checkout.stripe.test/cs_norm'
)
resp = client.get('/checkout/dictia-cloud?period=quarterly',
resp = client.get('/checkout/cloud-basic?period=quarterly',
follow_redirects=False)
assert resp.status_code == 303
assert mock_create.call_args.kwargs['period'] == 'monthly'
@@ -512,7 +544,7 @@ def test_checkout_route_requires_login():
db.create_all()
try:
with app.test_client() as client:
resp = client.get('/checkout/dictia-cloud',
resp = client.get('/checkout/cloud-basic',
follow_redirects=False)
assert resp.status_code == 302
assert '/login' in resp.headers['Location']
@@ -522,6 +554,28 @@ def test_checkout_route_requires_login():
_clear_stripe_env()
def test_checkout_route_pro_plus_redirects_to_contact():
"""Pro+ is quote-only — /checkout/pro-plus must redirect to /contact?pro-plus=1, NOT to Stripe."""
with app.app_context():
_disable_csrf()
_clear_stripe_env()
# Even with full Stripe config, Pro+ never reaches Stripe
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
db.create_all()
try:
user = _make_user(email='rt-proplus@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
resp = client.get('/checkout/pro-plus', follow_redirects=False)
assert resp.status_code == 302
assert '/contact' in resp.headers['Location']
assert 'pro-plus=1' in resp.headers['Location']
finally:
db.session.rollback()
db.drop_all()
_clear_stripe_env()
def test_checkout_route_friendly_message_when_stripe_not_configured():
with app.app_context():
_disable_csrf()
@@ -532,7 +586,7 @@ def test_checkout_route_friendly_message_when_stripe_not_configured():
user = _make_user(email='rt-noconfig@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
resp = client.get('/checkout/dictia-cloud',
resp = client.get('/checkout/cloud-basic',
follow_redirects=False)
assert resp.status_code == 302
assert '/tarifs' in resp.headers['Location']

View File

@@ -82,7 +82,7 @@ def _make_event(event_type, obj_data, event_id='evt_test_123'):
def _make_checkout_session(customer='cus_test', subscription='sub_test',
plan_slug='dictia-cloud', period='monthly',
plan_slug='cloud-basic', period='monthly',
email=None, user_id='1'):
return {
'id': 'cs_test_abc',
@@ -99,7 +99,7 @@ def _make_checkout_session(customer='cus_test', subscription='sub_test',
def _make_subscription_obj(sub_id='sub_test', customer='cus_test',
status='active', period_end=1730000000,
plan_slug='dictia-cloud', period='monthly'):
plan_slug='cloud-basic', period='monthly'):
return {
'id': sub_id,
'customer': customer,
@@ -298,7 +298,7 @@ def test_checkout_session_completed_creates_subscription_and_sets_user_status():
sub = Subscription.query.filter_by(stripe_subscription_id='sub_test').first()
assert sub is not None
assert sub.user_id == user.id
assert sub.plan_slug == 'dictia-cloud'
assert sub.plan_slug == 'cloud-basic'
assert sub.period == 'monthly'
assert sub.status == 'active'
assert sub.current_period_end is not None
@@ -454,7 +454,7 @@ def test_subscription_updated_updates_status_and_period_end():
user_id=user.id,
stripe_customer_id='cus_upd',
stripe_subscription_id='sub_upd',
plan_slug='dictia-cloud',
plan_slug='cloud-basic',
period='monthly',
status='active',
current_period_end=datetime(2025, 1, 1),
@@ -539,7 +539,7 @@ def test_subscription_deleted_marks_canceled():
user_id=user.id,
stripe_customer_id='cus_del',
stripe_subscription_id='sub_del',
plan_slug='dictia-cloud',
plan_slug='cloud-basic',
period='monthly',
status='active',
)
@@ -586,7 +586,7 @@ def test_invoice_payment_succeeded_recovers_past_due():
user_id=user.id,
stripe_customer_id='cus_paysucc',
stripe_subscription_id='sub_paysucc',
plan_slug='dictia-cloud',
plan_slug='cloud-basic',
period='monthly',
status='past_due',
)
@@ -632,7 +632,7 @@ def test_invoice_payment_failed_marks_past_due():
user_id=user.id,
stripe_customer_id='cus_payfail',
stripe_subscription_id='sub_payfail',
plan_slug='dictia-cloud',
plan_slug='cloud-basic',
period='monthly',
status='active',
)