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

@@ -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']