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

Remplace l'ancien pricing (DictIA 8 / 16 / Cloud) par la nouvelle structure
canonique v7.0 : 4 forfaits + 1 sentinel quote-only.

Changements clés :
- pricing_card.html : signature étendue (badge, recommended, capacity_audio,
  capacity_storage, gpu, yearly_renewal, cta_label) + format prix server-side
  avec NBSP OQLF (5998 -> 5 998 $)
- _pricing_tiers.html : 4 cards (Cloud Basic 189$, Cloud Essentiel 349$,
  Cloud Pro 549$+485$ RECOMMANDÉ, DictIA Local 5998$ An1) + chip Pro+
  soumission -> /contact?pro-plus=1
- plans.py : refonte complète avec yearly_renewal_env (DictIA Local An 2+ =
  500$/an) + is_quote_only sentinel (Pro+ -> redirect /contact, jamais Stripe)
- routes.py : Pro+ intercepté avant le flow Stripe Checkout
- env.stripe.example : nouveau naming STRIPE_CLOUD_BASIC|ESSENTIEL|PRO_*
  + STRIPE_DICTIA_LOCAL_SETUP/RENEWAL_YEARLY
- tarifs.html : header "Quatre forfaits", matrice comparative 4 colonnes,
  FAQ enrichie (7 questions incluant DictIA Local + onboarding Pro + Pro+)
- fonctionnalites.html : section Architecture refondue (4 cards v7.0)
- landing.html : ROI footnote + cycle "189$" + wave "189$/mois" actualisés
- roi_calculator.js : recalibrage sur Cloud ESSENTIEL 349$ × 12 = 4188$/an
- routes.py marketing : FAQ "DictIA 8 et 16" -> "DictIA LOCAL"
- contact.html : "déploiements DictIA 16" -> "Cloud PRO" + "DictIA LOCAL"

Tests :
- test_marketing_landing_template.py : assertions prix v7.0 (189/349/549/5998),
  4 slugs (cloud-basic, cloud-essentiel, cloud-pro, dictia-local), Pro+ chip,
  capacity chips, RECOMMANDÉ sur Cloud PRO
- test_marketing_secondary_pages.py : 4 cards + Pro+ chip + matrice 4 col +
  FAQ 7 questions
- test_stripe_checkout.py : env vars v7.0, slugs cloud-basic/cloud-pro/
  dictia-local + nouveau test pro-plus -> /contact + tests setup pour Cloud PRO
  et DictIA Local
- test_stripe_webhook.py : plan_slug metadata cloud-basic

Status : 28/28 Stripe checkout + 17/17 webhook + 93/98 marketing pass
(les 5 marketing failures sont pré-existantes, non liées au pricing :
test_landing_has_main_nav et test_footer_links_complete = /blog manquant ;
test_trust_bar_has_eyebrow_factual_phrasing + 2 tests conformite =
casing eyebrow + entité é — vérifié par git stash baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 21:06:12 -04:00
parent e8c7e5cd43
commit 1c4cafaf69
16 changed files with 648 additions and 301 deletions

View File

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

View File

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

View File

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

View File

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