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:
@@ -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 450 $' in body, "Missing DictIA 8 setup price"
|
||||
assert '173 $' in body, "Missing DictIA 8 monthly price"
|
||||
assert '5 750 $' in body, "Missing DictIA 16 setup price"
|
||||
assert '201 $' in body, "Missing DictIA 16 monthly price"
|
||||
assert '369 $' 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 $' in body, "Missing Cloud BASIC monthly price (189 $)"
|
||||
assert '349 $' in body, "Missing Cloud ESSENTIEL monthly price (349 $)"
|
||||
assert '549 $' in body, "Missing Cloud PRO monthly price (549 $)"
|
||||
assert '485 $' in body, "Missing Cloud PRO setup/onboarding price (485 $)"
|
||||
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():
|
||||
"""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é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():
|
||||
"""Pricing card features piped through | safe — ' ' must render single-escaped, not double."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
# GPU sizes use NBSP
|
||||
assert 'GPU 8 Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
|
||||
assert 'GPU 16 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&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 h audio/mois' in body, "Missing Cloud BASIC capacity chip"
|
||||
assert '100 Go' in body, "Missing Cloud BASIC storage chip"
|
||||
assert '~660 h audio/mois' in body, "Missing Cloud PRO capacity chip"
|
||||
assert '500 Go' in body, "Missing Cloud PRO storage chip"
|
||||
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
|
||||
assert 'Conforme Loi 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 25' in body, "Loi 25 must use NBSP"
|
||||
# Negative: NO double-escape
|
||||
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)."""
|
||||
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 % 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 $/mois' in body or '173 $/mois' in body
|
||||
assert '189 $/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 924 $' in body or '3 924 $' in body
|
||||
# Wave solution card pricing
|
||||
assert '173 $/mois' in body or 'Dès 173' in body
|
||||
# Wave solution card pricing — v7.0 Cloud BASIC entry price
|
||||
assert '189 $/mois' in body or 'Dès 189' in body
|
||||
# Cadre — Loi 25 fine
|
||||
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
|
||||
|
||||
|
||||
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 450 $' in body
|
||||
assert '5 750 $' in body
|
||||
assert '369 $' 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 $' in body
|
||||
assert '349 $' in body
|
||||
assert '549 $' in body
|
||||
assert '485 $' in body # Cloud Pro onboarding
|
||||
assert '5 998 $' 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&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 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 %+' 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
|
||||
assert '&nbsp;' not in body
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user