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:
@@ -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.
|
# 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
|
# 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
|
# Format: price_xxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
# Naming convention in this codebase: STRIPE_<PLAN>_<TYPE>
|
# Naming convention in this codebase: STRIPE_<PLAN>_<TYPE>
|
||||||
# PLAN = DICTIA_8 | DICTIA_16 | DICTIA_CLOUD
|
# PLAN = CLOUD_BASIC | CLOUD_ESSENTIEL | CLOUD_PRO | DICTIA_LOCAL
|
||||||
# TYPE = SETUP (one-time, hardware only) | MONTHLY | YEARLY
|
# TYPE = SETUP (one-time) | MONTHLY | YEARLY | RENEWAL_YEARLY (DictIA Local An 2+)
|
||||||
#
|
#
|
||||||
# Yearly Price = Monthly Price × 12 × 0.85 (15 % discount). Configure both
|
# 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
|
# Cloud BASIC : 189 $/mo (no setup) — solopreneur, petite équipe, ~165 h audio/mo
|
||||||
# STRIPE_DICTIA_8_SETUP=price_xxx
|
# STRIPE_CLOUD_BASIC_MONTHLY=price_xxx
|
||||||
# STRIPE_DICTIA_8_MONTHLY=price_xxx
|
# STRIPE_CLOUD_BASIC_YEARLY=price_xxx
|
||||||
# STRIPE_DICTIA_8_YEARLY=price_xxx
|
|
||||||
|
|
||||||
# DictIA 16 (16-channel hardware bundle): 5 750 $ setup + 201 $/mo
|
# Cloud ESSENTIEL : 349 $/mo (no setup) — cabinet en croissance, ~330 h audio/mo
|
||||||
# STRIPE_DICTIA_16_SETUP=price_xxx
|
# STRIPE_CLOUD_ESSENTIEL_MONTHLY=price_xxx
|
||||||
# STRIPE_DICTIA_16_MONTHLY=price_xxx
|
# STRIPE_CLOUD_ESSENTIEL_YEARLY=price_xxx
|
||||||
# STRIPE_DICTIA_16_YEARLY=price_xxx
|
|
||||||
|
|
||||||
# DictIA Cloud (SaaS-only, no hardware): 369 $/mo
|
# Cloud PRO : 549 $/mo + 485 $ onboarding (one-time) — usage intensif, ~660 h audio/mo
|
||||||
# STRIPE_DICTIA_CLOUD_MONTHLY=price_xxx
|
# STRIPE_CLOUD_PRO_SETUP=price_xxx
|
||||||
# STRIPE_DICTIA_CLOUD_YEARLY=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
|
# Required Stripe Dashboard configuration
|
||||||
@@ -56,10 +60,13 @@
|
|||||||
# Apple Pay requires verifying the dictia.ca domain via the Stripe-hosted
|
# Apple Pay requires verifying the dictia.ca domain via the Stripe-hosted
|
||||||
# `.well-known/apple-developer-merchantid-domain-association` file.
|
# `.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 monthly Price (CAD, billing_scheme=per_unit)
|
||||||
# - One recurring yearly Price (CAD, = monthly × 12 × 0.85)
|
# - 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
|
# 5. Create a webhook endpoint (B-2.8) pointing at
|
||||||
# https://your-domain.example/checkout/webhooks/stripe
|
# https://your-domain.example/checkout/webhooks/stripe
|
||||||
|
|||||||
@@ -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
|
Centralized plan registry. Stripe Price IDs are resolved from environment
|
||||||
variables — set STRIPE_<PLAN>_<PERIOD> env vars in production. The slug
|
variables — set STRIPE_<PLAN>_<PERIOD> env vars in production. The slug
|
||||||
(`dictia-8`, `dictia-16`, `dictia-cloud`) is the canonical identifier
|
(`cloud-basic`, `cloud-essentiel`, `cloud-pro`, `dictia-local`) is the
|
||||||
used throughout the codebase (URL params, webhook metadata, audit logs).
|
canonical identifier used throughout the codebase (URL params, webhook
|
||||||
|
metadata, audit logs).
|
||||||
|
|
||||||
Pricing reference (CAD, pre-tax — TPS/TVQ added by Stripe automatic_tax):
|
v7.0 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)
|
- Cloud BASIC : 189 $/mo recurring (no setup)
|
||||||
- DictIA 16: 5 750$ setup (one-time) + 201$/mo recurring (or yearly = 201 × 12 × 0.85)
|
yearly = 189 × 12 × 0.85 ≈ 1 928 $/an
|
||||||
- DictIA Cloud: 369$/mo recurring (or yearly = 369 × 12 × 0.85)
|
- 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
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -17,20 +28,31 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Plan:
|
class Plan:
|
||||||
"""A DictIA subscription plan.
|
"""A DictIA subscription plan (v7.0).
|
||||||
|
|
||||||
Stripe Price IDs are resolved lazily from environment variables — the
|
Stripe Price IDs are resolved lazily from environment variables — the
|
||||||
Plan instance itself only stores the variable names. This lets the
|
Plan instance itself only stores the variable names. This lets the
|
||||||
application boot without Stripe credentials (CI, dev branches) and
|
application boot without Stripe credentials (CI, dev branches) and
|
||||||
keeps secrets out of source control.
|
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
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
description_fr: str
|
description_fr: str
|
||||||
has_setup_fee: bool
|
has_setup_fee: bool
|
||||||
monthly_env: str
|
monthly_env: Optional[str] = None
|
||||||
yearly_env: str
|
yearly_env: Optional[str] = None
|
||||||
setup_env: Optional[str] = None # only set for plans with a setup fee
|
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]:
|
def setup_price_id(self) -> Optional[str]:
|
||||||
if not self.has_setup_fee or not self.setup_env:
|
if not self.has_setup_fee or not self.setup_env:
|
||||||
@@ -38,47 +60,91 @@ class Plan:
|
|||||||
return os.environ.get(self.setup_env)
|
return os.environ.get(self.setup_env)
|
||||||
|
|
||||||
def monthly_price_id(self) -> Optional[str]:
|
def monthly_price_id(self) -> Optional[str]:
|
||||||
|
if not self.monthly_env:
|
||||||
|
return None
|
||||||
return os.environ.get(self.monthly_env)
|
return os.environ.get(self.monthly_env)
|
||||||
|
|
||||||
def yearly_price_id(self) -> Optional[str]:
|
def yearly_price_id(self) -> Optional[str]:
|
||||||
|
if not self.yearly_env:
|
||||||
|
return None
|
||||||
return os.environ.get(self.yearly_env)
|
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:
|
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():
|
if self.has_setup_fee and not self.setup_price_id():
|
||||||
return False
|
return False
|
||||||
return bool(self.monthly_price_id() and self.yearly_price_id())
|
return bool(self.monthly_price_id() and self.yearly_price_id())
|
||||||
|
|
||||||
def price_id_for_period(self, period: str) -> Optional[str]:
|
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()
|
return self.yearly_price_id() if period == 'yearly' else self.monthly_price_id()
|
||||||
|
|
||||||
|
|
||||||
PLANS: Dict[str, Plan] = {
|
PLANS: Dict[str, Plan] = {
|
||||||
'dictia-8': Plan(
|
'cloud-basic': Plan(
|
||||||
slug='dictia-8',
|
slug='cloud-basic',
|
||||||
name='DictIA 8',
|
name='Cloud BASIC',
|
||||||
description_fr='Boîtier 8 canaux + transcription IA locale (poste de travail).',
|
description_fr='Cloud souverain QC — 189 $/mo · solopreneur, petite équipe.',
|
||||||
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.',
|
|
||||||
has_setup_fee=False,
|
has_setup_fee=False,
|
||||||
monthly_env='STRIPE_DICTIA_CLOUD_MONTHLY',
|
monthly_env='STRIPE_CLOUD_BASIC_MONTHLY',
|
||||||
yearly_env='STRIPE_DICTIA_CLOUD_YEARLY',
|
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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ def checkout(plan):
|
|||||||
flash('Forfait inconnu.', 'danger')
|
flash('Forfait inconnu.', 'danger')
|
||||||
return redirect(url_for('marketing.tarifs'))
|
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')
|
period = request.args.get('period', 'monthly')
|
||||||
if period not in VALID_PERIODS:
|
if period not in VALID_PERIODS:
|
||||||
period = 'monthly'
|
period = 'monthly'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ TESTIMONIALS = [
|
|||||||
FAQ = [
|
FAQ = [
|
||||||
{
|
{
|
||||||
'q': 'Comment fonctionne la transcription?',
|
'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 8 et 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 25, l\'audit trail (art. 3.5 LPRPSP), le registre des consentements (art. 14) et l\'EFVP (art. 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 LOCAL — RTX 5070 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 25, l\'audit trail (art. 3.5 LPRPSP), le registre des consentements (art. 14) et l\'EFVP (art. 3.3) sont fournis par défaut.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'q': 'Quels formats audio/vidéo sont supportés?',
|
'q': 'Quels formats audio/vidéo sont supportés?',
|
||||||
@@ -49,7 +49,7 @@ FAQ = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'q': 'La transcription est-elle vraiment confidentielle?',
|
'q': 'La transcription est-elle vraiment confidentielle?',
|
||||||
'a': 'Avec DictIA 8 et 16, vos données ne quittent jamais votre bureau — le traitement est 100 % 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 LOCAL, vos données ne quittent jamais votre bureau — le traitement est 100 % 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?',
|
'q': 'Teams Copilot est-il légal pour mes réunions?',
|
||||||
|
|||||||
@@ -49,8 +49,6 @@
|
|||||||
--color-green-700: oklch(52.7% 0.154 150.069);
|
--color-green-700: oklch(52.7% 0.154 150.069);
|
||||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||||
--color-green-900: oklch(39.3% 0.095 152.535);
|
--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-500: oklch(69.6% 0.17 162.48);
|
||||||
--color-emerald-600: oklch(59.6% 0.145 163.225);
|
--color-emerald-600: oklch(59.6% 0.145 163.225);
|
||||||
--color-emerald-700: oklch(50.8% 0.118 165.612);
|
--color-emerald-700: oklch(50.8% 0.118 165.612);
|
||||||
@@ -1169,12 +1167,18 @@
|
|||||||
.min-w-\[180px\] {
|
.min-w-\[180px\] {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
.min-w-\[260px\] {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
.min-w-\[300px\] {
|
.min-w-\[300px\] {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
.min-w-\[720px\] {
|
.min-w-\[720px\] {
|
||||||
min-width: 720px;
|
min-width: 720px;
|
||||||
}
|
}
|
||||||
|
.min-w-\[820px\] {
|
||||||
|
min-width: 820px;
|
||||||
|
}
|
||||||
.min-w-full {
|
.min-w-full {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1765,6 +1769,9 @@
|
|||||||
.border-brand-b3\/15 {
|
.border-brand-b3\/15 {
|
||||||
border-color: color-mix(in oklab, #c026d3 15%, transparent);
|
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-brand-b3\/60 {
|
||||||
border-color: color-mix(in oklab, #c026d3 60%, transparent);
|
border-color: color-mix(in oklab, #c026d3 60%, transparent);
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2081,9 @@
|
|||||||
.bg-brand-b2\/20 {
|
.bg-brand-b2\/20 {
|
||||||
background-color: color-mix(in oklab, #06b6d4 20%, transparent);
|
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 {
|
.bg-brand-b3 {
|
||||||
background-color: #c026d3;
|
background-color: #c026d3;
|
||||||
}
|
}
|
||||||
@@ -2083,6 +2093,9 @@
|
|||||||
.bg-brand-b3\/60 {
|
.bg-brand-b3\/60 {
|
||||||
background-color: color-mix(in oklab, #c026d3 60%, transparent);
|
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 {
|
.bg-brand-bg {
|
||||||
background-color: #f7f9fc;
|
background-color: #f7f9fc;
|
||||||
}
|
}
|
||||||
@@ -3304,6 +3317,12 @@
|
|||||||
color: color-mix(in oklab, var(--color-white) 70%, transparent);
|
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 {
|
.text-white\/80 {
|
||||||
color: color-mix(in srgb, #fff 80%, transparent);
|
color: color-mix(in srgb, #fff 80%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
|||||||
@@ -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) :
|
// Hypotheses transparentes (cf. footnote dans landing.html) :
|
||||||
// - 80% du temps de transcription manuelle est économisé
|
// - 80% du temps de transcription manuelle est économisé
|
||||||
// - 220 jours ouvrables/an
|
// - 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() {
|
window.roiCalculator = function roiCalculator() {
|
||||||
return {
|
return {
|
||||||
users: 5,
|
users: 5,
|
||||||
@@ -13,7 +13,8 @@ window.roiCalculator = function roiCalculator() {
|
|||||||
return Math.round(hoursSaved * this.rate);
|
return Math.round(hoursSaved * this.rate);
|
||||||
},
|
},
|
||||||
get payback() {
|
get payback() {
|
||||||
const annualCost = 5750 + (201 * 12);
|
// Cloud ESSENTIEL annual cost (no setup fee)
|
||||||
|
const annualCost = 349 * 12;
|
||||||
if (this.savings <= 0) return null;
|
if (this.savings <= 0) return null;
|
||||||
return (annualCost / this.savings) * 12;
|
return (annualCost / this.savings) * 12;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
Args:
|
||||||
slug : URL-safe identifier (goes into href, NOT piped through | safe — autoescape protects URL)
|
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)
|
name : Display name (piped through | safe — entity-free expected: "Cloud BASIC", "DictIA LOCAL"…)
|
||||||
price_setup : Setup price string with NBSP (e.g. '3 450 $') — piped through | safe
|
|
||||||
price_monthly : Monthly price string with NBSP (e.g. '173 $') — piped through | safe
|
|
||||||
target : Target audience tagline — piped through | safe (may contain entities)
|
target : Target audience tagline — piped through | safe (may contain entities)
|
||||||
features : List of feature strings, each piped through | safe (will contain entities)
|
features : List of feature strings, each piped through | safe (may contain 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
|
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 h audio/mois'
|
||||||
|
capacity_storage : Capacity chip (storage) — e.g. '100 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)
|
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
|
The numeric `setup` / `monthly` / `yearly_renewal` are formatted server-side
|
||||||
(cf. trust bar "Pré-inscription ouverte") — LPC art. 219 hygiene.
|
with French (fr-CA) thousands separator (NBSP) — `5998` → `5 998 $`.
|
||||||
The button macro autoescapes its `text` arg, so `name` MUST NOT contain HTML entities
|
This avoids requiring callers to remember OQLF NBSP conventions for every
|
||||||
(verified: "DictIA 8", "DictIA 16", "DictIA Cloud" are all entity-free). #}
|
price string.
|
||||||
{%- 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 %}">
|
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 998' (OQLF thousands separator) #}
|
||||||
|
{%- macro fmt_price(n) -%}
|
||||||
|
{%- set s = n | string -%}
|
||||||
|
{%- if s | length > 3 -%}{{ s[:-3] }} {{ 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 %}
|
{% 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="bg-white p-6 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>
|
{# Eyebrow badge (optional) — Cloud · Souverain QC / Local · 100% hors-ligne #}
|
||||||
<p class="text-sm text-brand-navy/70">{{ target | safe }}</p>
|
{% 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>
|
||||||
<div class="mb-6">
|
|
||||||
<div class="text-4xl font-black grad-text">{{ price_setup | safe }}</div>
|
{# Pricing block — 3 layouts:
|
||||||
<div class="text-sm text-brand-navy/70">+ {{ price_monthly | safe }} / mois</div>
|
- 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) }} $</div>
|
||||||
|
<div class="text-xs text-brand-navy/70 mt-2">An 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) }} $/an</strong> dès An 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) }} $<span class="text-base text-brand-navy/60 font-bold"> / mois</span></div>
|
||||||
|
<div class="text-xs text-brand-navy/70 mt-2">+ {{ fmt_price(setup) }} $ onboarding (unique)</div>
|
||||||
|
{% else %}
|
||||||
|
{# Cloud Basic / Essentiel — monthly only #}
|
||||||
|
<div class="text-4xl font-black grad-text leading-none">{{ fmt_price(monthly) }} $<span class="text-base text-brand-navy/60 font-bold"> / mois</span></div>
|
||||||
|
<div class="text-xs text-brand-navy/70 mt-2">Aucun frais d'installation</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% for f in features %}
|
||||||
<li class="flex items-start gap-2 text-sm text-brand-navy/80">
|
<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>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{# CTA #}
|
||||||
{% from 'macros/button.html' import button %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro -%}
|
{%- endmacro -%}
|
||||||
|
|||||||
@@ -1,30 +1,113 @@
|
|||||||
{# Single source of truth for the 3 pricing tiers — used by landing.html#tarifs and /tarifs page.
|
{# Single source of truth for the v7.0 pricing — used by landing.html#tarifs and /tarifs page.
|
||||||
When prices change, edit ONLY this file. #}
|
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 %}
|
{% 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 %+ précision · 99+ langues',
|
||||||
|
'Diarisation pyannote (qui parle)',
|
||||||
|
'Résumés IA + Points d’action (Mistral Nemo 12B)',
|
||||||
|
'Exports SRT, VTT, TXT, JSON, DOCX',
|
||||||
|
'Hébergement OVH Beauharnois (QC)',
|
||||||
|
'Conforme Loi 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(
|
{{ pricing_card(
|
||||||
'dictia-8',
|
slug='cloud-basic',
|
||||||
'DictIA 8',
|
name='Cloud BASIC',
|
||||||
'3 450 $',
|
badge='Cloud · Souverain QC',
|
||||||
'173 $',
|
target='Solopreneur · petite équipe · usage occasionnel à régulier.',
|
||||||
'PME · Manufacturiers · RH · Services — local, vos données ne quittent jamais votre bureau.',
|
monthly=189,
|
||||||
['GPU 8 Go RTX', 'Volume illimité', 'WhisperX FR-CA', 'Diarisation 8 locuteurs', 'Support inclus']
|
capacity_audio='~165 h audio/mois',
|
||||||
|
capacity_storage='100 Go',
|
||||||
|
gpu='NVIDIA L4 partagé',
|
||||||
|
features=_baseline_features_cloud,
|
||||||
|
cta_label='Démarrer en Cloud'
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
{{ pricing_card(
|
{{ pricing_card(
|
||||||
'dictia-16',
|
slug='cloud-essentiel',
|
||||||
'DictIA 16',
|
name='Cloud ESSENTIEL',
|
||||||
'5 750 $',
|
badge='Cloud · Souverain QC',
|
||||||
'201 $',
|
target='Cabinet en croissance · usage quotidien soutenu.',
|
||||||
'Cabinets juridiques · CPA · Services financiers — local, Mistral 7B sur votre GPU.',
|
monthly=349,
|
||||||
['GPU 16 Go RTX', 'Mistral 7B local', 'Q&R sur enregistrement', 'Tout DictIA 8', 'Support prioritaire'],
|
capacity_audio='~330 h audio/mois',
|
||||||
recommended=True
|
capacity_storage='200 Go',
|
||||||
|
gpu='NVIDIA L4 partagé étendu',
|
||||||
|
features=_baseline_features_cloud,
|
||||||
|
cta_label='Choisir Essentiel'
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
{{ pricing_card(
|
{{ pricing_card(
|
||||||
'dictia-cloud',
|
slug='cloud-pro',
|
||||||
'DictIA Cloud',
|
name='Cloud PRO',
|
||||||
'0 $',
|
badge='Cloud · Souverain QC',
|
||||||
'369 $',
|
recommended=True,
|
||||||
'Organismes · Municipalités · Multi-sites — Cloud QC, opérationnel en 48 h, aucun matériel requis.',
|
target='Organisation établie · usage intensif multi-postes.',
|
||||||
['Hébergé OVH Beauharnois (Québec)', 'Opérationnel sous 48 h', 'Aucun matériel à gérer', 'SLA visé 99,9 %', 'Conforme Loi 25']
|
setup=485,
|
||||||
|
monthly=549,
|
||||||
|
capacity_audio='~660 h audio/mois',
|
||||||
|
capacity_storage='500 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 % hors-ligne',
|
||||||
|
target='Confidentialité maximale · 100 % hors-ligne chez vous.',
|
||||||
|
setup=5998,
|
||||||
|
yearly_renewal=500,
|
||||||
|
capacity_audio='~1 100 h audio/mois',
|
||||||
|
capacity_storage='2 To SSD',
|
||||||
|
gpu='RTX 5070 Ti 16 Go (dédié)',
|
||||||
|
features=[
|
||||||
|
'WhisperX Large-v3 · 99 %+ précision · 99+ langues',
|
||||||
|
'Diarisation pyannote (qui parle)',
|
||||||
|
'Résumés IA + Points d’action (Mistral 7B local)',
|
||||||
|
'Exports SRT, VTT, TXT, JSON, DOCX',
|
||||||
|
'GPU local dédié · transcription locale',
|
||||||
|
'Données jamais sortantes (chez vous)',
|
||||||
|
'500 $/an dès An 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 PRO ?</h3>
|
||||||
|
<p class="text-sm text-white/75 leading-relaxed">
|
||||||
|
> 660 h audio/mois · > 500 Go stockage · 7+ utilisateurs intensifs · multi-sites · SLA 99,9 % · SOC 2 Type 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>
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-bold mb-2 text-brand-navy">Bureau</h3>
|
<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">
|
<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 LOCAL et déploiements Cloud PRO corporatifs.
|
||||||
</p>
|
</p>
|
||||||
<address class="not-italic text-sm text-brand-navy/80 leading-relaxed">
|
<address class="not-italic text-sm text-brand-navy/80 leading-relaxed">
|
||||||
77 ch. de la Seigneurie<br>
|
77 ch. de la Seigneurie<br>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
.orb-float-a { animation: orb-float 8s ease-in-out infinite; }
|
.orb-float-a { animation: orb-float 8s ease-in-out infinite; }
|
||||||
.orb-float-b { animation: orb-float 11s ease-in-out infinite reverse; }
|
.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 {
|
@keyframes card-pulse-glow {
|
||||||
0%, 100% { box-shadow: 0 4px 20px rgba(37,99,235, 0.28); }
|
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); }
|
50% { box-shadow: 0 12px 40px rgba(37,99,235, 0.5); }
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
'title': 'Résumés & points d\'action',
|
'title': 'Résumés & 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.',
|
'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,
|
'icon': icon_document,
|
||||||
'chips': ['Mistral 7B local (DictIA 16+)', 'Templates pro', 'Décisions + actions', '2 h/jour récupérées']
|
'chips': ['Mistral Nemo 12B (Cloud) · Mistral 7B (Local)', 'Templates pro', 'Décisions + actions', '2 h/jour récupérées']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'n': '04',
|
'n': '04',
|
||||||
@@ -336,53 +336,65 @@
|
|||||||
<div class="text-center max-w-3xl mx-auto mb-14">
|
<div class="text-center max-w-3xl mx-auto mb-14">
|
||||||
<p class="eyebrow grad-text mb-4">ARCHITECTURE & INFRASTRUCTURE</p>
|
<p class="eyebrow grad-text mb-4">ARCHITECTURE & INFRASTRUCTURE</p>
|
||||||
<h2 id="architecture-title" class="text-[clamp(2rem,3vw,2.75rem)] font-black">
|
<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>
|
</h2>
|
||||||
<p class="text-lg text-white/70 mt-6">
|
<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 % hors-ligne chez vous. Toutes les formules incluent aucune limite utilisateurs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{%- set tiers = [
|
{%- set tiers = [
|
||||||
{
|
{
|
||||||
'name': 'DictIA 8',
|
'name': 'Cloud BASIC',
|
||||||
'tagline': 'Petit cabinet, mode local',
|
'tagline': 'Solopreneur · petite équipe',
|
||||||
'gpu': 'RTX 8 Go',
|
'gpu': 'NVIDIA L4 partagé',
|
||||||
'users': '2 à 5 utilisateurs',
|
'users': 'Aucune limite',
|
||||||
'setup': '3 450 $',
|
|
||||||
'monthly': '173 $',
|
|
||||||
'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 700 $)']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'DictIA 16',
|
|
||||||
'tagline': 'Cabinet moyen, IA locale complète',
|
|
||||||
'gpu': 'RTX 16 Go',
|
|
||||||
'users': '5 à 15 utilisateurs',
|
|
||||||
'setup': '5 750 $',
|
|
||||||
'monthly': '201 $',
|
|
||||||
'host': 'Local chez vous',
|
|
||||||
'llm': 'Mistral 7B inclus',
|
|
||||||
'recommended': False,
|
|
||||||
'features': ['Tout DictIA 8', 'Résumés Mistral 7B local', 'Chat Q&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',
|
|
||||||
'setup': '—',
|
'setup': '—',
|
||||||
'monthly': '369 $',
|
'monthly': '189 $',
|
||||||
'host': 'OVH Beauharnois (QC)',
|
'host': 'OVH Beauharnois (QC)',
|
||||||
'llm': 'Mistral 7B inclus',
|
'llm': 'Mistral Nemo 12B',
|
||||||
|
'recommended': False,
|
||||||
|
'features': ['Transcription WhisperX Large-v3', 'Diarisation pyannote', '~165 h audio/mois · 100 Go', 'Exports DOCX, PDF, SRT, VTT, TXT, JSON, MD', 'Self-service · 0 $ d’installation']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Cloud ESSENTIEL',
|
||||||
|
'tagline': 'Cabinet en croissance',
|
||||||
|
'gpu': 'L4 partagé étendu',
|
||||||
|
'users': 'Aucune limite',
|
||||||
|
'setup': '—',
|
||||||
|
'monthly': '349 $',
|
||||||
|
'host': 'OVH Beauharnois (QC)',
|
||||||
|
'llm': 'Mistral Nemo 12B',
|
||||||
|
'recommended': False,
|
||||||
|
'features': ['Tout Cloud BASIC', '~330 h audio/mois · 200 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 $',
|
||||||
|
'monthly': '549 $',
|
||||||
|
'host': 'OVH Beauharnois (QC)',
|
||||||
|
'llm': 'Mistral Nemo 12B',
|
||||||
'recommended': True,
|
'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 h audio/mois · 500 Go', 'GPU dédié priorité (latence garantie)', 'Onboarding assisté inclus (485 $ unique)', 'Multi-sites et télétravail']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DictIA LOCAL',
|
||||||
|
'tagline': '100 % hors-ligne · chez vous',
|
||||||
|
'gpu': 'RTX 5070 Ti 16 Go',
|
||||||
|
'users': 'Aucune limite',
|
||||||
|
'setup': '5 998 $',
|
||||||
|
'monthly': '500 $/an dès An 2',
|
||||||
|
'host': 'Chez le client',
|
||||||
|
'llm': 'Mistral 7B local',
|
||||||
|
'recommended': False,
|
||||||
|
'features': ['Tout Cloud PRO en mode local', '~1 100 h audio/mois · 2 To SSD', 'GPU local dédié', 'Données jamais sortantes', 'Admissible achat direct gouv. (≤ 34 700 $)']
|
||||||
}
|
}
|
||||||
] -%}
|
] -%}
|
||||||
|
|
||||||
<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 %}
|
{% for tier in tiers %}
|
||||||
<article class="ani-fade {% if tier.recommended %}card-pulse-glow grad-bg p-[1.5px] rounded{% endif %} relative h-full"
|
<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;"
|
style="--delay: {{ loop.index0 * 100 }}ms;"
|
||||||
@@ -395,22 +407,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="bg-brand-navy2 p-6 rounded border border-white/[0.08] h-full flex flex-col">
|
<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]">
|
<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>
|
<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 }}</p>
|
<p class="text-xs uppercase tracking-wider text-white/60">{{ tier.tagline | safe }}</p>
|
||||||
</header>
|
</header>
|
||||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 mb-5 text-xs">
|
<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">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">Utilisateurs</dt><dd class="text-white">{{ tier.users | safe }}</dd>
|
||||||
<dt class="text-white/60">Hébergement</dt><dd class="text-white">{{ tier.host }}</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 }}</dd>
|
<dt class="text-white/60">LLM résumés</dt><dd class="text-white">{{ tier.llm | safe }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="mb-5 pb-5 border-b border-white/[0.08]">
|
<div class="mb-5 pb-5 border-b border-white/[0.08]">
|
||||||
{% if tier.setup != '—' %}
|
{% if tier.setup != '—' %}
|
||||||
<div class="text-3xl font-black grad-text leading-none">{{ tier.setup | safe }}</div>
|
<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 }} / mois</div>
|
<div class="text-xs text-white/60 mt-1">{% if tier.name == 'DictIA LOCAL' %}An 1 · puis {{ tier.monthly | safe }}{% else %}setup unique + {{ tier.monthly | safe }} / mois{% endif %}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-3xl font-black grad-text leading-none">{{ tier.monthly | safe }}</div>
|
<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’installation</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-2 mb-6 flex-grow text-sm" role="list">
|
<ul class="space-y-2 mb-6 flex-grow text-sm" role="list">
|
||||||
@@ -428,16 +440,16 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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]">
|
<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 & 16)</p>
|
<p class="eyebrow grad-text mb-3">INCLUS DANS LE FORFAIT DICTIA LOCAL (5 998 $ AN 1)</p>
|
||||||
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 text-sm" role="list">
|
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 text-sm" role="list">
|
||||||
{% for inc in [
|
{% for inc in [
|
||||||
'Fourniture du PC + GPU',
|
'PC + GPU RTX 5070 Ti + 2 To SSD',
|
||||||
'Configuration complète',
|
'Configuration complète',
|
||||||
'Installation sur site',
|
'Installation sur site',
|
||||||
'Formation équipe (2–3 h)',
|
'Formation équipe (2–3 h)',
|
||||||
'Support démarrage 30 jours'
|
'1<sup>re</sup> année logiciel + support'
|
||||||
] %}
|
] %}
|
||||||
<li class="flex items-start gap-2 text-white/80">
|
<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>
|
<span class="text-brand-b3 mt-0.5" aria-hidden="true">{{ icon_check | safe }}</span>
|
||||||
@@ -448,7 +460,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-white/60 text-center mt-6 max-w-2xl mx-auto">
|
<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 700 $ — Règlement sur les contrats d'approvisionnement, art. 15).
|
DictIA LOCAL est admissible à l'achat direct gouvernemental sans appel d'offres (seuil 34 700 $ — Règlement sur les contrats d'approvisionnement, art. 15). Pour > 660 h audio/mois ou SLA 99,9 %, demandez une <a href="/contact?pro-plus=1" class="grad-text underline">soumission Pro+</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@
|
|||||||
|
|
||||||
{# H2 (canonique : phrase clé en cyan/grad) #}
|
{# 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;">
|
<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 minutes — conforme Barreau, CPA Québec et ChAD.
|
Transcription IA locale en 2 minutes — Audio → Texte · Résumés IA · Conforme Loi 25 & ordres professionnels
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{# Sub canonique #}
|
{# Sub canonique #}
|
||||||
@@ -1044,8 +1044,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-center text-[10px] mb-3.5 text-brand-b1/65">Zéro frais caché · Du jamais vu</p>
|
<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">
|
<div class="flex items-baseline gap-2">
|
||||||
{# Round 5 — big number en grad-text (blue->cyan->fuchsia) #}
|
{# Round 5 — big number en grad-text (blue->cyan->fuchsia) — Cloud BASIC v7.0 #}
|
||||||
<span class="font-black leading-none text-5xl grad-text">173</span>
|
<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>
|
<span class="text-sm text-brand-navy/55 mb-0.5 font-semibold">$ / mois</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 mt-2">
|
<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">
|
<div class="absolute inset-0 pointer-events-none" :style="`opacity: ${orderOp}`" aria-hidden="true">
|
||||||
{% for sol in [
|
{% for sol in [
|
||||||
{'text': '~2 min pour 1h d\'audio', 'x': 20, 'y': 12},
|
{'text': '~2 min pour 1h d\'audio', 'x': 20, 'y': 12},
|
||||||
{'text': 'Dès 173 $/mois — illimité', 'x': 64, 'y': 10},
|
{'text': 'Dès 189 $/mois — illimité', 'x': 64, 'y': 10},
|
||||||
{'text': '90 %+ d\'économies', 'x': 74, 'y': 38},
|
{'text': '90 %+ d\'économies', 'x': 74, 'y': 38},
|
||||||
{'text': '100 % Confidentiel — Hébergé au Qc', 'x': 18, 'y': 40}
|
{'text': '100 % 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>
|
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>
|
</div>
|
||||||
<p class="text-xs text-brand-navy/70 mt-6 text-center">
|
<p class="text-xs text-brand-navy/70 mt-6 text-center">
|
||||||
Hypothèses : 80 % du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à DictIA 16 (5 750 $ + 201 $/mois). Estimation à titre indicatif.
|
Hypothèses : 80 % du temps de transcription manuelle économisé, 220 jours ouvrables/an, comparé à Cloud ESSENTIEL (349 $/mois × 12 = 4 188 $/an). Estimation à titre indicatif.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends 'marketing/base.html' %}
|
{% extends 'marketing/base.html' %}
|
||||||
|
|
||||||
{% block title %}Tarifs DictIA — 3 forfaits transparents en CAD (369 $/mois Cloud, à partir de 173 $/mois on-premise){% 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 : DictIA 8 (PME), DictIA 16 (cabinets juridiques) et DictIA Cloud (organismes). Volume illimité, zéro frais par utilisateur, taxes en sus.{% 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 %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -10,18 +10,18 @@
|
|||||||
<div class="max-w-[820px] mx-auto px-6 text-center">
|
<div class="max-w-[820px] mx-auto px-6 text-center">
|
||||||
<p class="eyebrow grad-text mb-4">TARIFS</p>
|
<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">
|
<h1 id="page-title" class="text-[clamp(2.25rem,4vw,3.5rem)] font-black mb-4">
|
||||||
Trois forfaits : <span class="grad-text">choisissez votre infrastructure</span>.
|
Quatre forfaits : <span class="grad-text">choisissez votre infrastructure</span>.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-white/80">
|
<p class="text-lg text-white/80">
|
||||||
Volume illimité, zéro frais par utilisateur. Tarifs en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %).
|
3 Cloud souverains au Québec + 1 100 % local hors-ligne. Aucune limite utilisateurs, tarifs en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# ===== 3 PRICING TIERS ===== #}
|
{# ===== 4 PRICING TIERS + Pro+ ===== #}
|
||||||
<section class="bg-brand-bg py-20" aria-labelledby="forfaits-title">
|
<section class="bg-brand-bg py-20" aria-labelledby="forfaits-title">
|
||||||
<div class="max-w-[1200px] mx-auto px-6">
|
<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' %}
|
{% include 'marketing/_partials/_pricing_tiers.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -37,33 +37,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded border border-brand-border">
|
<div class="overflow-x-auto rounded border border-brand-border">
|
||||||
<table class="w-full min-w-[720px] text-sm">
|
<table class="w-full min-w-[820px] text-sm">
|
||||||
<caption class="sr-only">Comparaison détaillée des 3 forfaits DictIA sur 8 caractéristiques</caption>
|
<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">
|
<thead class="bg-brand-bg">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="text-left p-4 font-bold text-brand-navy">Caractéristique</th>
|
<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">Cloud BASIC</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">Cloud ESSENTIEL</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 PRO</th>
|
||||||
|
<th scope="col" class="p-4 font-bold text-brand-navy">DictIA LOCAL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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>' -%}
|
{%- 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">
|
<tbody class="divide-y divide-brand-border">
|
||||||
{% for row in [
|
{% for row in [
|
||||||
{'name': 'Hébergement', 'd8': 'Sur place (vos murs)', 'd16': 'Sur place (vos murs)', 'cloud': 'OVH Beauharnois (QC)'},
|
{'name': 'Hébergement', 'basic': 'OVH Beauharnois (QC)', 'ess': 'OVH Beauharnois (QC)', 'pro': 'OVH Beauharnois (QC)', 'local': 'Chez le client (100 % hors-ligne)'},
|
||||||
{'name': 'GPU', 'd8': '8 Go RTX', 'd16': '16 Go RTX', 'cloud': 'Mutualisé (géré)'},
|
{'name': 'GPU', 'basic': 'NVIDIA L4 partagé', 'ess': 'L4 partagé étendu', 'pro': 'L4 dédié priorité', 'local': 'RTX 5070 Ti 16 Go'},
|
||||||
{'name': 'Volume audio', 'd8': 'Illimité', 'd16': 'Illimité', 'cloud': 'Illimité'},
|
{'name': 'Capacité audio', 'basic': '~165 h/mois', 'ess': '~330 h/mois', 'pro': '~660 h/mois', 'local': '~1 100 h/mois'},
|
||||||
{'name': 'Utilisateurs', 'd8': 'Illimité', 'd16': 'Illimité', 'cloud': 'Illimité'},
|
{'name': 'Stockage', 'basic': '100 Go', 'ess': '200 Go', 'pro': '500 Go', 'local': '2 To SSD'},
|
||||||
{'name': 'Diarisation', 'd8': '8 locuteurs', 'd16': '8 locuteurs', 'cloud': '8 locuteurs'},
|
{'name': 'Utilisateurs', 'basic': 'Aucune limite', 'ess': 'Aucune limite', 'pro': 'Aucune limite', 'local': 'Aucune limite'},
|
||||||
{'name': 'Résumés Mistral 7B local', 'd8': '—', 'd16': svg_check, 'cloud': svg_check ~ '<span class="ml-1">(mutualisé)</span>'},
|
{'name': 'Diarisation pyannote', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check},
|
||||||
{'name': 'Q&R sur enregistrement', 'd8': '—', 'd16': svg_check, 'cloud': svg_check},
|
{'name': 'Résumés IA + Points d’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': 'Délai de mise en service', 'd8': '~2 semaines', 'd16': '~2 semaines', 'cloud': '48 h'}
|
{'name': 'Conformité Loi 25', 'basic': svg_check, 'ess': svg_check, 'pro': svg_check, 'local': svg_check ~ '<span class="ml-1 text-xs">+ 100 % hors-ligne</span>'},
|
||||||
|
{'name': 'SLA', 'basic': '99,5 %', 'ess': '99,5 %', 'pro': '99,5 %', 'local': '— (resp. client)'},
|
||||||
|
{'name': 'Délai de mise en service', 'basic': '48 h', 'ess': '48 h', 'pro': '48 h + onboarding', 'local': '~2 semaines'}
|
||||||
] %}
|
] %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row" class="text-left p-4 font-semibold text-brand-navy/80">{{ row.name | safe }}</th>
|
<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.basic | 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.ess | 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.pro | safe }}</td>
|
||||||
|
<td class="p-4 text-center text-brand-navy/80">{{ row.local | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-brand-navy/70 mt-6 text-center max-w-3xl mx-auto">
|
<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 > 660 h audio/mois, multi-sites ou SLA 99,9 %, demandez une <a href="/contact?pro-plus=1" class="grad-text font-semibold hover:underline">soumission Pro+</a>. Questions : <a href="mailto:info@dictia.ca" class="grad-text font-semibold hover:underline">info@dictia.ca</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -86,11 +90,13 @@
|
|||||||
|
|
||||||
<div class="divide-y divide-brand-border border-y border-brand-border">
|
<div class="divide-y divide-brand-border border-y border-brand-border">
|
||||||
{% for item in [
|
{% 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 : les taxes (TPS 5 % + TVQ 9,975 %) 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': '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 : les taxes (TPS 5 % + TVQ 9,975 %) et, pour DictIA Local, le matériel inclus dans le 5 998 $ An 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 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': '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': 'Le tarif on-premise inclut-il le matériel GPU?', 'a': 'Le tarif setup (3 450 $ pour DictIA 8 ou 5 750 $ 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 ; nous fournissons une liste de cartes RTX recommandées (RTX 4060 8 Go pour DictIA 8, RTX 4080/5080 16 Go pour DictIA 16) et pouvons faire l\'achat pour vous moyennant marge transparente.'},
|
{'q': 'Que comprend le 5 998 $ de DictIA Local?', 'a': 'Le forfait DictIA Local An 1 inclut : PC + GPU RTX 5070 Ti 16 Go + 2 To SSD, installation sur site, configuration sécurité, formation équipe, et la première année de licence logicielle. Dès l\'An 2, seul le renouvellement annuel de 500 $/an (mises à jour + support) est facturé.'},
|
||||||
|
{'q': 'Comment fonctionne le 485 $ d\'onboarding Cloud Pro?', 'a': 'Le forfait Cloud Pro inclut un onboarding assisté unique (485 $) couvrant : configuration des comptes, importation des hotwords métier, formation équipe (1 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': '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 %). É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 % (équivalent ~10 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 : > 660 h audio/mois, > 500 Go de stockage, 7+ utilisateurs simultanés intensifs, multi-sites, SLA renforcé 99,9 %, audits SOC 2 Type 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">
|
<div x-data="{ open: false }" class="py-2">
|
||||||
<h3>
|
<h3>
|
||||||
|
|||||||
@@ -326,54 +326,66 @@ def test_pricing_section_present():
|
|||||||
assert 'TPS' in body and 'TVQ' in body, "Missing tax disclaimer (TPS/TVQ)"
|
assert 'TPS' in body and 'TVQ' in body, "Missing tax disclaimer (TPS/TVQ)"
|
||||||
|
|
||||||
|
|
||||||
def test_pricing_3_tiers_with_canonical_amounts():
|
def test_pricing_4_tiers_with_canonical_amounts_v7():
|
||||||
"""Pricing has 3 tiers: DictIA 8 (3450/173), DictIA 16 (5750/201), DictIA Cloud (0/369)."""
|
"""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()
|
client = app.test_client()
|
||||||
body = client.get('/').data.decode('utf-8')
|
body = client.get('/').data.decode('utf-8')
|
||||||
# Names
|
# 4 forfait names
|
||||||
for name in ['DictIA 8', 'DictIA 16', 'DictIA Cloud']:
|
for name in ['Cloud BASIC', 'Cloud ESSENTIEL', 'Cloud PRO', 'DictIA LOCAL']:
|
||||||
assert name in body, f"Missing pricing tier: {name}"
|
assert name in body, f"Missing v7.0 pricing tier: {name}"
|
||||||
# Canonical prices with NBSP per OQLF
|
# Canonical prices with NBSP per OQLF (formatted by macro)
|
||||||
assert '3 450 $' in body, "Missing DictIA 8 setup price"
|
assert '189 $' in body, "Missing Cloud BASIC monthly price (189 $)"
|
||||||
assert '173 $' in body, "Missing DictIA 8 monthly price"
|
assert '349 $' in body, "Missing Cloud ESSENTIEL monthly price (349 $)"
|
||||||
assert '5 750 $' in body, "Missing DictIA 16 setup price"
|
assert '549 $' in body, "Missing Cloud PRO monthly price (549 $)"
|
||||||
assert '201 $' in body, "Missing DictIA 16 monthly price"
|
assert '485 $' in body, "Missing Cloud PRO setup/onboarding price (485 $)"
|
||||||
assert '369 $' in body, "Missing DictIA Cloud monthly price (canonical 369$)"
|
assert '5 998 $' in body, "Missing DictIA LOCAL An 1 price (5 998 $)"
|
||||||
|
assert '500 $/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():
|
def test_pricing_recommended_tier_is_cloud_pro():
|
||||||
"""DictIA 16 is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame)."""
|
"""Cloud PRO is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame)."""
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
body = client.get('/').data.decode('utf-8')
|
body = client.get('/').data.decode('utf-8')
|
||||||
assert 'RECOMMAND' in body, "Missing RECOMMANDÉ badge"
|
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)
|
# 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, \
|
assert 'grad-bg p-[1.5px] rounded' in body, \
|
||||||
"Missing FlexiHub gradient frame on recommended tier (rounded 4px)"
|
"Missing FlexiHub gradient frame on recommended tier"
|
||||||
|
|
||||||
|
|
||||||
def test_pricing_cta_uses_reserver_pre_launch_wording():
|
def test_pricing_cta_labels_v7():
|
||||||
"""CTAs say 'Réserver' not 'Choisir' — pre-launch LPC art. 219 hygiene."""
|
"""CTAs reflect v7.0 forfait choice (Démarrer en Cloud / Choisir Essentiel / Commander Pro / Configurer DictIA Local)."""
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
body = client.get('/').data.decode('utf-8')
|
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 f'href="/checkout/{slug}"' in body, f"Missing checkout link for {slug}"
|
||||||
assert 'Réserver DictIA 8' in body or 'Ré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é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():
|
def test_pricing_features_use_safe_filter_no_double_escape():
|
||||||
"""Pricing card features piped through | safe — ' ' must render single-escaped, not double."""
|
"""Pricing card features piped through | safe — ' ' must render single-escaped, not double."""
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
body = client.get('/').data.decode('utf-8')
|
body = client.get('/').data.decode('utf-8')
|
||||||
# GPU sizes use NBSP
|
# Capacity chips use NBSP
|
||||||
assert 'GPU 8 Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
|
assert '~165 h audio/mois' in body, "Missing Cloud BASIC capacity chip"
|
||||||
assert 'GPU 16 Go RTX' in body, "GPU 16 Go feature missing or NBSP double-escaped"
|
assert '100 Go' in body, "Missing Cloud BASIC storage chip"
|
||||||
# Q&R card must use French Q&R, not English Q&A
|
assert '~660 h audio/mois' in body, "Missing Cloud PRO capacity chip"
|
||||||
assert 'Q&R' in body, "DictIA 16 must mention Q&R (French), not Q&A (English)"
|
assert '500 Go' in body, "Missing Cloud PRO storage chip"
|
||||||
assert 'Q&A' not in body, "Must use French Q&R consistently — no English Q&A"
|
assert '2 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
|
# Loi 25 with NBSP
|
||||||
assert 'Conforme Loi 25' in body, "Conforme Loi 25 must use NBSP"
|
assert 'Loi 25' in body, "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)"
|
|
||||||
# Negative: NO double-escape
|
# Negative: NO double-escape
|
||||||
assert '&nbsp;' not in body, "NBSP must not be double-escaped — | safe missing on pricing macro?"
|
assert '&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)."""
|
"""pricing_card uses cta_url.rstrip('/') so href never has '//' (regression guard)."""
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
body = client.get('/').data.decode('utf-8')
|
body = client.get('/').data.decode('utf-8')
|
||||||
# All 3 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/dictia-X
|
# All 4 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/<slug>
|
||||||
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 single-slash href for {slug}"
|
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}"
|
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 'Retranscription humaine' in body
|
||||||
assert 'IA cloud américaine' in body
|
assert 'IA cloud américaine' in body
|
||||||
assert 'NON CONFORME' 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 'Loi 25 conforme' in body
|
||||||
assert '100 % hébergé au Québec' in body or '100 % hébergé au Québec' in body
|
assert '100 % hébergé au Québec' in body or '100 % hébergé au Québec' in body
|
||||||
# Phase animation hooks
|
# Phase animation hooks
|
||||||
@@ -811,9 +823,9 @@ def test_round2_wave_section_present():
|
|||||||
# Canonical pain labels
|
# Canonical pain labels
|
||||||
assert '4 à 6h pour transcrire 1h' in body
|
assert '4 à 6h pour transcrire 1h' in body
|
||||||
assert 'Délais de 48h à 5 jours' 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 '~2 min pour 1h d' in body
|
||||||
assert '173 $/mois' in body or '173 $/mois' in body
|
assert '189 $/mois' in body or '189 $/mois' in body
|
||||||
# Alpine state for interactive slider
|
# Alpine state for interactive slider
|
||||||
assert 'onMove($event)' in body
|
assert 'onMove($event)' in body
|
||||||
assert 'isMobile' 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')
|
body = client.get('/').data.decode('utf-8')
|
||||||
# Cycle section savings
|
# Cycle section savings
|
||||||
assert '3 924 $' in body or '3 924 $' in body
|
assert '3 924 $' in body or '3 924 $' in body
|
||||||
# Wave solution card pricing
|
# Wave solution card pricing — v7.0 Cloud BASIC entry price
|
||||||
assert '173 $/mois' in body or 'Dès 173' in body
|
assert '189 $/mois' in body or 'Dès 189' in body
|
||||||
# Cadre — Loi 25 fine
|
# Cadre — Loi 25 fine
|
||||||
assert '25 M$' in body or '25 M$' in body
|
assert '25 M$' in body or '25 M$' in body
|
||||||
|
|
||||||
|
|||||||
@@ -33,41 +33,51 @@ def test_tarifs_has_h1_with_anchor():
|
|||||||
assert '<h1' in body and 'choisissez votre infrastructure' in body
|
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()
|
client = app.test_client()
|
||||||
body = client.get('/tarifs').data.decode('utf-8')
|
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
|
assert tier in body
|
||||||
# Canonical NBSP prices
|
# Canonical NBSP prices (v7.0)
|
||||||
assert '3 450 $' in body
|
assert '189 $' in body
|
||||||
assert '5 750 $' in body
|
assert '349 $' in body
|
||||||
assert '369 $' in body
|
assert '549 $' in body
|
||||||
assert 'href="/checkout/dictia-8"' in body
|
assert '485 $' in body # Cloud Pro onboarding
|
||||||
assert 'href="/checkout/dictia-16"' in body
|
assert '5 998 $' in body # DictIA Local An 1
|
||||||
assert 'href="/checkout/dictia-cloud"' in body
|
# 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()
|
client = app.test_client()
|
||||||
body = client.get('/tarifs').data.decode('utf-8')
|
body = client.get('/tarifs').data.decode('utf-8')
|
||||||
assert 'matrix-title' in body
|
assert 'matrix-title' in body
|
||||||
assert '<caption class="sr-only">' in body
|
assert '<caption class="sr-only">' in body
|
||||||
assert 'scope="col"' in body
|
assert 'scope="col"' in body
|
||||||
assert 'scope="row"' in body
|
assert 'scope="row"' in body
|
||||||
# 8 row keywords
|
# v7.0 row keywords (matches the rows in tarifs.html)
|
||||||
for kw in ['Hébergement', 'GPU', 'Volume audio', 'Utilisateurs',
|
for kw in ['Hébergement', 'GPU', 'Capacité audio', 'Stockage', 'Utilisateurs',
|
||||||
'Diarisation', 'Mistral 7B local', 'Q&R', 'Délai']:
|
'Diarisation pyannote', 'Loi 25', 'SLA', 'Délai']:
|
||||||
assert kw in body, f"Missing matrix row keyword: {kw}"
|
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()
|
client = app.test_client()
|
||||||
body = client.get('/tarifs').data.decode('utf-8')
|
body = client.get('/tarifs').data.decode('utf-8')
|
||||||
assert 'tarifs-faq-title' in body
|
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}"
|
assert f'tarifs-faq-panel-{i}' in body, f"Missing tarifs FAQ panel {i}"
|
||||||
# Alpine accordion bindings
|
# 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)
|
# Each accordion button has focus-visible (WCAG 2.4.7/2.4.11)
|
||||||
assert 'focus-visible:outline-2' in body
|
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')
|
body = client.get('/fonctionnalites').data.decode('utf-8')
|
||||||
# NBSP entities
|
# NBSP entities
|
||||||
assert '95 %+' in body, "WhisperX precision NBSP entity"
|
assert '95 %+' in body, "WhisperX precision NBSP entity"
|
||||||
assert 'GPU 8 Go RTX' not in body # Bento card calls don't use 8 Go RTX (that's pricing)
|
|
||||||
assert 'Q&R' in body, "French Q&R (not Q&A)"
|
|
||||||
# No double-escape
|
# No double-escape
|
||||||
assert '&nbsp;' not in body
|
assert '&nbsp;' not in body
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ from src.models.user import User # noqa: E402
|
|||||||
|
|
||||||
_PRICE_ENV_VARS = (
|
_PRICE_ENV_VARS = (
|
||||||
'STRIPE_SECRET_KEY',
|
'STRIPE_SECRET_KEY',
|
||||||
'STRIPE_DICTIA_8_SETUP', 'STRIPE_DICTIA_8_MONTHLY', 'STRIPE_DICTIA_8_YEARLY',
|
# v7.0 — Cloud BASIC / ESSENTIEL (no setup) + Cloud PRO (setup) + DictIA LOCAL (setup + yearly renewal)
|
||||||
'STRIPE_DICTIA_16_SETUP', 'STRIPE_DICTIA_16_MONTHLY', 'STRIPE_DICTIA_16_YEARLY',
|
'STRIPE_CLOUD_BASIC_MONTHLY', 'STRIPE_CLOUD_BASIC_YEARLY',
|
||||||
'STRIPE_DICTIA_CLOUD_MONTHLY', 'STRIPE_DICTIA_CLOUD_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():
|
def test_get_plan_returns_known_plan():
|
||||||
from src.billing.plans import get_plan, 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 plan is not None
|
||||||
assert isinstance(plan, Plan)
|
assert isinstance(plan, Plan)
|
||||||
assert plan.slug == 'dictia-cloud'
|
assert plan.slug == 'cloud-basic'
|
||||||
assert plan.has_setup_fee is False
|
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():
|
def test_plan_is_configured_when_env_set():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
try:
|
try:
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cloud_m'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_basic_m'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cloud_y'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_basic_y'
|
||||||
from src.billing.plans import get_plan
|
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:
|
finally:
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
|
|
||||||
@@ -131,21 +133,49 @@ def test_plan_is_not_configured_when_env_missing():
|
|||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
try:
|
try:
|
||||||
from src.billing.plans import get_plan
|
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:
|
finally:
|
||||||
_clear_stripe_env()
|
_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()
|
_clear_stripe_env()
|
||||||
try:
|
try:
|
||||||
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8_m'
|
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
|
||||||
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8_y'
|
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
|
||||||
# NO STRIPE_DICTIA_8_SETUP
|
# NO STRIPE_CLOUD_PRO_SETUP
|
||||||
from src.billing.plans import get_plan
|
from src.billing.plans import get_plan
|
||||||
assert get_plan('dictia-8').is_configured() is False
|
assert get_plan('cloud-pro').is_configured() is False
|
||||||
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_8_setup'
|
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_pro_setup'
|
||||||
assert get_plan('dictia-8').is_configured() is True
|
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:
|
finally:
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
|
|
||||||
@@ -203,29 +233,30 @@ def test_get_or_create_customer_reuses_existing():
|
|||||||
# 10-13. create_checkout_session
|
# 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():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_8_SETUP'] = 'price_setup'
|
os.environ['STRIPE_CLOUD_PRO_SETUP'] = 'price_setup'
|
||||||
os.environ['STRIPE_DICTIA_8_MONTHLY'] = 'price_8m'
|
os.environ['STRIPE_CLOUD_PRO_MONTHLY'] = 'price_pro_m'
|
||||||
os.environ['STRIPE_DICTIA_8_YEARLY'] = 'price_8y'
|
os.environ['STRIPE_CLOUD_PRO_YEARLY'] = 'price_pro_y'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
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, \
|
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
|
||||||
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
|
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
|
||||||
mock_cust.return_value = MagicMock(id='cus_x')
|
mock_cust.return_value = MagicMock(id='cus_x')
|
||||||
mock_sess.return_value = MagicMock(url='https://checkout.stripe.test/cs_x')
|
mock_sess.return_value = MagicMock(url='https://checkout.stripe.test/cs_x')
|
||||||
from src.billing.stripe_client import create_checkout_session
|
from src.billing.stripe_client import create_checkout_session
|
||||||
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',
|
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
|
||||||
)
|
)
|
||||||
kwargs = mock_sess.call_args.kwargs
|
kwargs = mock_sess.call_args.kwargs
|
||||||
assert len(kwargs['line_items']) == 2
|
assert len(kwargs['line_items']) == 2
|
||||||
assert kwargs['line_items'][0]['price'] == 'price_setup'
|
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['mode'] == 'subscription'
|
||||||
assert kwargs['currency'] == 'cad'
|
assert kwargs['currency'] == 'cad'
|
||||||
assert kwargs['automatic_tax']['enabled'] is True
|
assert kwargs['automatic_tax']['enabled'] is True
|
||||||
@@ -239,27 +270,28 @@ def test_create_checkout_session_includes_setup_for_hardware_plan():
|
|||||||
_clear_stripe_env()
|
_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():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
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, \
|
with patch('src.billing.stripe_client.stripe.Customer.create') as mock_cust, \
|
||||||
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
|
patch('src.billing.stripe_client.stripe.checkout.Session.create') as mock_sess:
|
||||||
mock_cust.return_value = MagicMock(id='cus_y')
|
mock_cust.return_value = MagicMock(id='cus_y')
|
||||||
mock_sess.return_value = MagicMock(url='https://x/cs_y')
|
mock_sess.return_value = MagicMock(url='https://x/cs_y')
|
||||||
from src.billing.stripe_client import create_checkout_session
|
from src.billing.stripe_client import create_checkout_session
|
||||||
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',
|
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
|
||||||
)
|
)
|
||||||
kwargs = mock_sess.call_args.kwargs
|
kwargs = mock_sess.call_args.kwargs
|
||||||
assert len(kwargs['line_items']) == 1
|
assert len(kwargs['line_items']) == 1
|
||||||
assert kwargs['line_items'][0]['price'] == 'price_cm'
|
assert kwargs['line_items'][0]['price'] == 'price_bm'
|
||||||
finally:
|
finally:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
db.drop_all()
|
db.drop_all()
|
||||||
@@ -270,8 +302,8 @@ def test_create_checkout_session_uses_yearly_price_when_period_yearly():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='yearly@example.qc.ca', name='Dan')
|
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')
|
mock_sess.return_value = MagicMock(url='https://x/cs_z')
|
||||||
from src.billing.stripe_client import create_checkout_session
|
from src.billing.stripe_client import create_checkout_session
|
||||||
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',
|
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
|
||||||
)
|
)
|
||||||
kwargs = mock_sess.call_args.kwargs
|
kwargs = mock_sess.call_args.kwargs
|
||||||
assert kwargs['line_items'][0]['price'] == 'price_cy'
|
assert kwargs['line_items'][0]['price'] == 'price_by'
|
||||||
finally:
|
finally:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
db.drop_all()
|
db.drop_all()
|
||||||
@@ -296,8 +328,8 @@ def test_create_checkout_session_includes_metadata():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_bm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_by'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='meta@example.qc.ca', name='Eve')
|
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')
|
mock_sess.return_value = MagicMock(url='https://x/cs_q')
|
||||||
from src.billing.stripe_client import create_checkout_session
|
from src.billing.stripe_client import create_checkout_session
|
||||||
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',
|
success_url='https://x.ca/success', cancel_url='https://x.ca/cancel',
|
||||||
)
|
)
|
||||||
kwargs = mock_sess.call_args.kwargs
|
kwargs = mock_sess.call_args.kwargs
|
||||||
meta = kwargs['metadata']
|
meta = kwargs['metadata']
|
||||||
assert meta['dictia_user_id'] == str(user.id)
|
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'
|
assert meta['dictia_period'] == 'monthly'
|
||||||
# Subscription-level metadata too (used by webhook B-2.8)
|
# Subscription-level metadata too (used by webhook B-2.8)
|
||||||
sub_meta = kwargs['subscription_data']['metadata']
|
sub_meta = kwargs['subscription_data']['metadata']
|
||||||
assert sub_meta['dictia_user_id'] == str(user.id)
|
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'
|
assert sub_meta['dictia_period'] == 'monthly'
|
||||||
finally:
|
finally:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -356,15 +388,15 @@ def test_create_checkout_session_raises_on_invalid_period():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='badperiod@example.qc.ca')
|
user = _make_user(email='badperiod@example.qc.ca')
|
||||||
from src.billing.stripe_client import create_checkout_session
|
from src.billing.stripe_client import create_checkout_session
|
||||||
try:
|
try:
|
||||||
create_checkout_session(
|
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',
|
success_url='https://x/s', cancel_url='https://x/c',
|
||||||
)
|
)
|
||||||
raise AssertionError('Expected ValueError')
|
raise AssertionError('Expected ValueError')
|
||||||
@@ -388,7 +420,7 @@ def test_create_checkout_session_raises_when_stripe_not_configured():
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
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/s', cancel_url='https://x/c',
|
success_url='https://x/s', cancel_url='https://x/c',
|
||||||
)
|
)
|
||||||
raise AssertionError('Expected StripeNotConfiguredError')
|
raise AssertionError('Expected StripeNotConfiguredError')
|
||||||
@@ -404,7 +436,7 @@ def test_create_checkout_session_raises_when_plan_env_missing():
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
# NO price IDs for dictia-cloud
|
# NO price IDs for cloud-basic
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='noprice@example.qc.ca')
|
user = _make_user(email='noprice@example.qc.ca')
|
||||||
@@ -413,7 +445,7 @@ def test_create_checkout_session_raises_when_plan_env_missing():
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
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/s', cancel_url='https://x/c',
|
success_url='https://x/s', cancel_url='https://x/c',
|
||||||
)
|
)
|
||||||
raise AssertionError('Expected StripeNotConfiguredError')
|
raise AssertionError('Expected StripeNotConfiguredError')
|
||||||
@@ -434,8 +466,8 @@ def test_checkout_route_redirects_to_stripe_url():
|
|||||||
_disable_csrf()
|
_disable_csrf()
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='rt-redir@example.qc.ca', name='Frank')
|
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(
|
mock_create.return_value = MagicMock(
|
||||||
url='https://checkout.stripe.test/cs_redir'
|
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)
|
follow_redirects=False)
|
||||||
assert resp.status_code == 303
|
assert resp.status_code == 303
|
||||||
assert resp.headers['Location'] == 'https://checkout.stripe.test/cs_redir'
|
assert resp.headers['Location'] == 'https://checkout.stripe.test/cs_redir'
|
||||||
# Ensure routes called the helper with the right args
|
# Ensure routes called the helper with the right args
|
||||||
mock_create.assert_called_once()
|
mock_create.assert_called_once()
|
||||||
call_kwargs = mock_create.call_args.kwargs
|
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'
|
assert call_kwargs['period'] == 'monthly'
|
||||||
finally:
|
finally:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -484,8 +516,8 @@ def test_checkout_route_normalizes_invalid_period_to_monthly():
|
|||||||
_disable_csrf()
|
_disable_csrf()
|
||||||
_clear_stripe_env()
|
_clear_stripe_env()
|
||||||
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_MONTHLY'] = 'price_cm'
|
os.environ['STRIPE_CLOUD_BASIC_MONTHLY'] = 'price_cm'
|
||||||
os.environ['STRIPE_DICTIA_CLOUD_YEARLY'] = 'price_cy'
|
os.environ['STRIPE_CLOUD_BASIC_YEARLY'] = 'price_cy'
|
||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
user = _make_user(email='rt-period@example.qc.ca')
|
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(
|
mock_create.return_value = MagicMock(
|
||||||
url='https://checkout.stripe.test/cs_norm'
|
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)
|
follow_redirects=False)
|
||||||
assert resp.status_code == 303
|
assert resp.status_code == 303
|
||||||
assert mock_create.call_args.kwargs['period'] == 'monthly'
|
assert mock_create.call_args.kwargs['period'] == 'monthly'
|
||||||
@@ -512,7 +544,7 @@ def test_checkout_route_requires_login():
|
|||||||
db.create_all()
|
db.create_all()
|
||||||
try:
|
try:
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
resp = client.get('/checkout/dictia-cloud',
|
resp = client.get('/checkout/cloud-basic',
|
||||||
follow_redirects=False)
|
follow_redirects=False)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert '/login' in resp.headers['Location']
|
assert '/login' in resp.headers['Location']
|
||||||
@@ -522,6 +554,28 @@ def test_checkout_route_requires_login():
|
|||||||
_clear_stripe_env()
|
_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():
|
def test_checkout_route_friendly_message_when_stripe_not_configured():
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
_disable_csrf()
|
_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')
|
user = _make_user(email='rt-noconfig@example.qc.ca')
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
_login_session(client, user)
|
_login_session(client, user)
|
||||||
resp = client.get('/checkout/dictia-cloud',
|
resp = client.get('/checkout/cloud-basic',
|
||||||
follow_redirects=False)
|
follow_redirects=False)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert '/tarifs' in resp.headers['Location']
|
assert '/tarifs' in resp.headers['Location']
|
||||||
|
|||||||
@@ -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',
|
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'):
|
email=None, user_id='1'):
|
||||||
return {
|
return {
|
||||||
'id': 'cs_test_abc',
|
'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',
|
def _make_subscription_obj(sub_id='sub_test', customer='cus_test',
|
||||||
status='active', period_end=1730000000,
|
status='active', period_end=1730000000,
|
||||||
plan_slug='dictia-cloud', period='monthly'):
|
plan_slug='cloud-basic', period='monthly'):
|
||||||
return {
|
return {
|
||||||
'id': sub_id,
|
'id': sub_id,
|
||||||
'customer': customer,
|
'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()
|
sub = Subscription.query.filter_by(stripe_subscription_id='sub_test').first()
|
||||||
assert sub is not None
|
assert sub is not None
|
||||||
assert sub.user_id == user.id
|
assert sub.user_id == user.id
|
||||||
assert sub.plan_slug == 'dictia-cloud'
|
assert sub.plan_slug == 'cloud-basic'
|
||||||
assert sub.period == 'monthly'
|
assert sub.period == 'monthly'
|
||||||
assert sub.status == 'active'
|
assert sub.status == 'active'
|
||||||
assert sub.current_period_end is not None
|
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,
|
user_id=user.id,
|
||||||
stripe_customer_id='cus_upd',
|
stripe_customer_id='cus_upd',
|
||||||
stripe_subscription_id='sub_upd',
|
stripe_subscription_id='sub_upd',
|
||||||
plan_slug='dictia-cloud',
|
plan_slug='cloud-basic',
|
||||||
period='monthly',
|
period='monthly',
|
||||||
status='active',
|
status='active',
|
||||||
current_period_end=datetime(2025, 1, 1),
|
current_period_end=datetime(2025, 1, 1),
|
||||||
@@ -539,7 +539,7 @@ def test_subscription_deleted_marks_canceled():
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
stripe_customer_id='cus_del',
|
stripe_customer_id='cus_del',
|
||||||
stripe_subscription_id='sub_del',
|
stripe_subscription_id='sub_del',
|
||||||
plan_slug='dictia-cloud',
|
plan_slug='cloud-basic',
|
||||||
period='monthly',
|
period='monthly',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
@@ -586,7 +586,7 @@ def test_invoice_payment_succeeded_recovers_past_due():
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
stripe_customer_id='cus_paysucc',
|
stripe_customer_id='cus_paysucc',
|
||||||
stripe_subscription_id='sub_paysucc',
|
stripe_subscription_id='sub_paysucc',
|
||||||
plan_slug='dictia-cloud',
|
plan_slug='cloud-basic',
|
||||||
period='monthly',
|
period='monthly',
|
||||||
status='past_due',
|
status='past_due',
|
||||||
)
|
)
|
||||||
@@ -632,7 +632,7 @@ def test_invoice_payment_failed_marks_past_due():
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
stripe_customer_id='cus_payfail',
|
stripe_customer_id='cus_payfail',
|
||||||
stripe_subscription_id='sub_payfail',
|
stripe_subscription_id='sub_payfail',
|
||||||
plan_slug='dictia-cloud',
|
plan_slug='cloud-basic',
|
||||||
period='monthly',
|
period='monthly',
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user