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>
785 lines
31 KiB
Python
785 lines
31 KiB
Python
"""Tests for B-2.8 — Stripe Webhook (subscription lifecycle + idempotency).
|
|
|
|
Covers:
|
|
- Signature verification (rejects missing/invalid; honors STRIPE_WEBHOOK_SECRET)
|
|
- Idempotency via WebhookEvent table
|
|
- Each event handler:
|
|
checkout.session.completed
|
|
customer.subscription.updated
|
|
customer.subscription.deleted
|
|
invoice.payment_succeeded
|
|
invoice.payment_failed
|
|
- User resolution order (stripe_customer_id → metadata → email)
|
|
- 500 retry path on handler exceptions
|
|
|
|
Mocks `_verify_event` (since real signatures need a real secret) and
|
|
`stripe.Subscription.retrieve` to avoid real Stripe API calls.
|
|
|
|
Note: pytest cannot collect this file on Windows native because src/init_db.py
|
|
imports `fcntl` (POSIX-only). Use tests/_run_stripe_webhook_windows.py.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webhook')
|
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
|
|
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
|
|
|
from src.app import app, db, bcrypt # noqa: E402
|
|
from src.models.user import User # noqa: E402
|
|
from src.models.subscription import Subscription # noqa: E402
|
|
from src.models.webhook_event import WebhookEvent # noqa: E402
|
|
|
|
|
|
_WEBHOOK_ENV_VARS = (
|
|
'STRIPE_SECRET_KEY',
|
|
'STRIPE_WEBHOOK_SECRET',
|
|
)
|
|
|
|
|
|
def _clear_webhook_env():
|
|
for k in _WEBHOOK_ENV_VARS:
|
|
os.environ.pop(k, None)
|
|
import stripe
|
|
stripe.api_key = None
|
|
|
|
|
|
def _disable_csrf():
|
|
app.config['WTF_CSRF_ENABLED'] = False
|
|
|
|
|
|
def _make_user(email='hookuser@example.qc.ca', password='Password!123',
|
|
username=None, name='Hook User',
|
|
stripe_customer_id=None,
|
|
subscription_status=None):
|
|
hashed = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
u = User(
|
|
username=username or email.split('@', 1)[0][:20],
|
|
email=email,
|
|
password=hashed,
|
|
email_verified=True,
|
|
name=name,
|
|
stripe_customer_id=stripe_customer_id,
|
|
subscription_status=subscription_status,
|
|
)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _make_event(event_type, obj_data, event_id='evt_test_123'):
|
|
"""Build a fake Stripe event for testing webhook handlers."""
|
|
return MagicMock(
|
|
id=event_id,
|
|
type=event_type,
|
|
data=MagicMock(object=obj_data),
|
|
)
|
|
|
|
|
|
def _make_checkout_session(customer='cus_test', subscription='sub_test',
|
|
plan_slug='cloud-basic', period='monthly',
|
|
email=None, user_id='1'):
|
|
return {
|
|
'id': 'cs_test_abc',
|
|
'customer': customer,
|
|
'subscription': subscription,
|
|
'customer_email': email,
|
|
'metadata': {
|
|
'dictia_user_id': user_id,
|
|
'dictia_plan_slug': plan_slug,
|
|
'dictia_period': period,
|
|
},
|
|
}
|
|
|
|
|
|
def _make_subscription_obj(sub_id='sub_test', customer='cus_test',
|
|
status='active', period_end=1730000000,
|
|
plan_slug='cloud-basic', period='monthly'):
|
|
return {
|
|
'id': sub_id,
|
|
'customer': customer,
|
|
'status': status,
|
|
'current_period_end': period_end,
|
|
'metadata': {'dictia_plan_slug': plan_slug, 'dictia_period': period},
|
|
}
|
|
|
|
|
|
def _make_invoice(sub_id='sub_test', customer='cus_test'):
|
|
return {
|
|
'id': 'in_test_123',
|
|
'customer': customer,
|
|
'subscription': sub_id,
|
|
}
|
|
|
|
|
|
def _post_webhook(client, payload=b'{}', signature='t=1,v1=fake'):
|
|
return client.post(
|
|
'/checkout/webhooks/stripe',
|
|
data=payload,
|
|
headers={'Stripe-Signature': signature, 'Content-Type': 'application/json'},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 1-3. Signature verification
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_webhook_rejects_missing_signature():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
with app.test_client() as client:
|
|
# No Stripe-Signature header
|
|
resp = client.post(
|
|
'/checkout/webhooks/stripe',
|
|
data=b'{}',
|
|
headers={'Content-Type': 'application/json'},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = resp.get_json()
|
|
assert body.get('error') == 'invalid_signature'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
def test_webhook_rejects_invalid_signature():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
with app.test_client() as client:
|
|
# Real construct_event will fail on bogus signature
|
|
resp = _post_webhook(client, payload=b'{"id":"evt_x"}',
|
|
signature='t=1,v1=bogus')
|
|
assert resp.status_code == 400
|
|
assert resp.get_json().get('error') == 'invalid_signature'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
def test_webhook_rejects_when_secret_not_configured():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
# NO STRIPE_WEBHOOK_SECRET
|
|
db.create_all()
|
|
try:
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client, payload=b'{"id":"evt_x"}')
|
|
assert resp.status_code == 400
|
|
assert resp.get_json().get('error') == 'invalid_signature'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 4. Idempotency
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_webhook_idempotent_on_duplicate_event_id():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='dup@example.qc.ca',
|
|
stripe_customer_id='cus_dup')
|
|
event = _make_event(
|
|
'checkout.session.completed',
|
|
_make_checkout_session(customer='cus_dup',
|
|
subscription='sub_dup',
|
|
user_id=str(user.id)),
|
|
event_id='evt_dup_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {
|
|
'current_period_end': 1730000000,
|
|
}
|
|
with app.test_client() as client:
|
|
# First call processes
|
|
resp1 = _post_webhook(client)
|
|
assert resp1.status_code == 200
|
|
assert resp1.get_json().get('received') is True
|
|
# Second call dedup'd
|
|
resp2 = _post_webhook(client)
|
|
assert resp2.status_code == 200
|
|
body2 = resp2.get_json()
|
|
assert body2.get('duplicate') is True
|
|
|
|
# Only one Subscription row + one WebhookEvent row
|
|
assert Subscription.query.filter_by(stripe_subscription_id='sub_dup').count() == 1
|
|
assert WebhookEvent.query.filter_by(stripe_event_id='evt_dup_1').count() == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 5. Unhandled event types
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_webhook_acks_unhandled_event_type():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
event = _make_event('customer.created', {'id': 'cus_new'},
|
|
event_id='evt_unhandled_1')
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
assert body.get('received') is True
|
|
assert body.get('handled') is False
|
|
# Recorded so Stripe stops retrying
|
|
assert WebhookEvent.query.filter_by(stripe_event_id='evt_unhandled_1').count() == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 6. checkout.session.completed — happy path
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_checkout_session_completed_creates_subscription_and_sets_user_status():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='create@example.qc.ca',
|
|
stripe_customer_id='cus_test')
|
|
event = _make_event(
|
|
'checkout.session.completed',
|
|
_make_checkout_session(customer='cus_test',
|
|
subscription='sub_test',
|
|
user_id=str(user.id)),
|
|
event_id='evt_create_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
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 == 'cloud-basic'
|
|
assert sub.period == 'monthly'
|
|
assert sub.status == 'active'
|
|
assert sub.current_period_end is not None
|
|
db.session.refresh(user)
|
|
assert user.subscription_status == 'active'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 7. User resolution by stripe_customer_id
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_checkout_session_completed_resolves_user_by_stripe_customer_id():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='resolve@example.qc.ca',
|
|
stripe_customer_id='cus_resolve')
|
|
session_obj = _make_checkout_session(
|
|
customer='cus_resolve',
|
|
subscription='sub_resolve',
|
|
user_id='',
|
|
)
|
|
# No metadata.dictia_user_id
|
|
session_obj['metadata'].pop('dictia_user_id', None)
|
|
event = _make_event('checkout.session.completed', session_obj,
|
|
event_id='evt_resolve_1')
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
sub = Subscription.query.filter_by(stripe_subscription_id='sub_resolve').first()
|
|
assert sub is not None
|
|
assert sub.user_id == user.id
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 8. User resolution by metadata when customer unknown
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_checkout_session_completed_resolves_user_by_metadata_when_customer_unknown():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='meta@example.qc.ca',
|
|
stripe_customer_id=None)
|
|
event = _make_event(
|
|
'checkout.session.completed',
|
|
_make_checkout_session(customer='cus_new',
|
|
subscription='sub_meta',
|
|
user_id=str(user.id)),
|
|
event_id='evt_meta_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
sub = Subscription.query.filter_by(stripe_subscription_id='sub_meta').first()
|
|
assert sub is not None
|
|
assert sub.user_id == user.id
|
|
db.session.refresh(user)
|
|
# stripe_customer_id is bound from the event
|
|
assert user.stripe_customer_id == 'cus_new'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 9. Idempotency via subscription_id uniqueness (different event IDs, same sub)
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_checkout_session_completed_idempotent_via_subscription_id_uniqueness():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='subdup@example.qc.ca',
|
|
stripe_customer_id='cus_subdup')
|
|
session_obj = _make_checkout_session(
|
|
customer='cus_subdup',
|
|
subscription='sub_subdup',
|
|
user_id=str(user.id),
|
|
)
|
|
event_a = _make_event('checkout.session.completed', session_obj,
|
|
event_id='evt_subdup_a')
|
|
event_b = _make_event('checkout.session.completed', session_obj,
|
|
event_id='evt_subdup_b')
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
mock_verify.return_value = event_a
|
|
resp1 = _post_webhook(client)
|
|
assert resp1.status_code == 200
|
|
mock_verify.return_value = event_b
|
|
resp2 = _post_webhook(client)
|
|
assert resp2.status_code == 200
|
|
|
|
# Only one Subscription row despite two distinct events
|
|
assert Subscription.query.filter_by(stripe_subscription_id='sub_subdup').count() == 1
|
|
# But both events recorded
|
|
assert WebhookEvent.query.filter_by(stripe_event_id='evt_subdup_a').count() == 1
|
|
assert WebhookEvent.query.filter_by(stripe_event_id='evt_subdup_b').count() == 1
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 10. customer.subscription.updated
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_subscription_updated_updates_status_and_period_end():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
from datetime import datetime
|
|
user = _make_user(email='upd@example.qc.ca',
|
|
stripe_customer_id='cus_upd',
|
|
subscription_status='active')
|
|
existing = Subscription(
|
|
user_id=user.id,
|
|
stripe_customer_id='cus_upd',
|
|
stripe_subscription_id='sub_upd',
|
|
plan_slug='cloud-basic',
|
|
period='monthly',
|
|
status='active',
|
|
current_period_end=datetime(2025, 1, 1),
|
|
)
|
|
db.session.add(existing)
|
|
db.session.commit()
|
|
|
|
event = _make_event(
|
|
'customer.subscription.updated',
|
|
_make_subscription_obj(sub_id='sub_upd', customer='cus_upd',
|
|
status='past_due',
|
|
period_end=1735689600), # 2025-01-01
|
|
event_id='evt_upd_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
db.session.refresh(existing)
|
|
assert existing.status == 'past_due'
|
|
assert existing.current_period_end is not None
|
|
db.session.refresh(user)
|
|
assert user.subscription_status == 'past_due'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 11. customer.subscription.updated when row missing
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_subscription_updated_creates_row_if_missing():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='race@example.qc.ca',
|
|
stripe_customer_id='cus_race')
|
|
event = _make_event(
|
|
'customer.subscription.updated',
|
|
_make_subscription_obj(sub_id='sub_race', customer='cus_race',
|
|
status='active'),
|
|
event_id='evt_race_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
sub = Subscription.query.filter_by(stripe_subscription_id='sub_race').first()
|
|
assert sub is not None
|
|
assert sub.user_id == user.id
|
|
assert sub.status == 'active'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 12. customer.subscription.deleted
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_subscription_deleted_marks_canceled():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='del@example.qc.ca',
|
|
stripe_customer_id='cus_del',
|
|
subscription_status='active')
|
|
existing = Subscription(
|
|
user_id=user.id,
|
|
stripe_customer_id='cus_del',
|
|
stripe_subscription_id='sub_del',
|
|
plan_slug='cloud-basic',
|
|
period='monthly',
|
|
status='active',
|
|
)
|
|
db.session.add(existing)
|
|
db.session.commit()
|
|
|
|
event = _make_event(
|
|
'customer.subscription.deleted',
|
|
_make_subscription_obj(sub_id='sub_del', customer='cus_del',
|
|
status='canceled'),
|
|
event_id='evt_del_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
db.session.refresh(existing)
|
|
assert existing.status == 'canceled'
|
|
db.session.refresh(user)
|
|
assert user.subscription_status == 'canceled'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 13. invoice.payment_succeeded — recovers past_due
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_invoice_payment_succeeded_recovers_past_due():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='paysucc@example.qc.ca',
|
|
stripe_customer_id='cus_paysucc',
|
|
subscription_status='past_due')
|
|
existing = Subscription(
|
|
user_id=user.id,
|
|
stripe_customer_id='cus_paysucc',
|
|
stripe_subscription_id='sub_paysucc',
|
|
plan_slug='cloud-basic',
|
|
period='monthly',
|
|
status='past_due',
|
|
)
|
|
db.session.add(existing)
|
|
db.session.commit()
|
|
|
|
event = _make_event(
|
|
'invoice.payment_succeeded',
|
|
_make_invoice(sub_id='sub_paysucc', customer='cus_paysucc'),
|
|
event_id='evt_paysucc_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
db.session.refresh(existing)
|
|
assert existing.status == 'active'
|
|
db.session.refresh(user)
|
|
assert user.subscription_status == 'active'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 14. invoice.payment_failed
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_invoice_payment_failed_marks_past_due():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='payfail@example.qc.ca',
|
|
stripe_customer_id='cus_payfail',
|
|
subscription_status='active')
|
|
existing = Subscription(
|
|
user_id=user.id,
|
|
stripe_customer_id='cus_payfail',
|
|
stripe_subscription_id='sub_payfail',
|
|
plan_slug='cloud-basic',
|
|
period='monthly',
|
|
status='active',
|
|
)
|
|
db.session.add(existing)
|
|
db.session.commit()
|
|
|
|
event = _make_event(
|
|
'invoice.payment_failed',
|
|
_make_invoice(sub_id='sub_payfail', customer='cus_payfail'),
|
|
event_id='evt_payfail_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
db.session.refresh(existing)
|
|
assert existing.status == 'past_due'
|
|
db.session.refresh(user)
|
|
assert user.subscription_status == 'past_due'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 15. Handler exception → 500 (Stripe retry)
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_handler_exception_returns_500_for_stripe_retry():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='boom@example.qc.ca',
|
|
stripe_customer_id='cus_boom')
|
|
event = _make_event(
|
|
'checkout.session.completed',
|
|
_make_checkout_session(customer='cus_boom',
|
|
subscription='sub_boom',
|
|
user_id=str(user.id)),
|
|
event_id='evt_boom_1',
|
|
)
|
|
# Patch the dispatch table entry so our raising stub fires inside the
|
|
# try/except handler block in stripe_webhook(). Patching the module
|
|
# attribute alone wouldn't help because _HANDLERS captures the original
|
|
# reference at module load.
|
|
from src.billing import webhooks as wh_mod
|
|
|
|
def _boom(_event):
|
|
raise RuntimeError('boom')
|
|
|
|
with patch.dict(wh_mod._HANDLERS,
|
|
{'checkout.session.completed': _boom}), \
|
|
patch('src.billing.webhooks._verify_event') as mock_verify:
|
|
mock_verify.return_value = event
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 500
|
|
assert resp.get_json().get('error') == 'handler_failed'
|
|
# No WebhookEvent recorded (rolled back)
|
|
assert WebhookEvent.query.filter_by(stripe_event_id='evt_boom_1').count() == 0
|
|
assert Subscription.query.filter_by(stripe_subscription_id='sub_boom').count() == 0
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 16. Metadata 'dictia_user_id' invalid → falls through
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_resolve_user_metadata_dictia_user_id_invalid_falls_through():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='emailfb@example.qc.ca',
|
|
stripe_customer_id=None)
|
|
session_obj = _make_checkout_session(
|
|
customer='cus_unknown',
|
|
subscription='sub_emailfb',
|
|
user_id='not-a-number',
|
|
email='emailfb@example.qc.ca',
|
|
)
|
|
event = _make_event('checkout.session.completed', session_obj,
|
|
event_id='evt_emailfb_1')
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
sub = Subscription.query.filter_by(stripe_subscription_id='sub_emailfb').first()
|
|
assert sub is not None
|
|
# Resolved by email
|
|
assert sub.user_id == user.id
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# 17. WebhookEvent records subscription_id for audit
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_webhook_event_records_subscription_id_for_audit():
|
|
with app.app_context():
|
|
_disable_csrf()
|
|
_clear_webhook_env()
|
|
os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake'
|
|
os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake'
|
|
db.create_all()
|
|
try:
|
|
user = _make_user(email='audit@example.qc.ca',
|
|
stripe_customer_id='cus_audit')
|
|
event = _make_event(
|
|
'checkout.session.completed',
|
|
_make_checkout_session(customer='cus_audit',
|
|
subscription='sub_audit',
|
|
user_id=str(user.id)),
|
|
event_id='evt_audit_1',
|
|
)
|
|
with patch('src.billing.webhooks._verify_event') as mock_verify, \
|
|
patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr:
|
|
mock_verify.return_value = event
|
|
mock_retr.return_value = {'current_period_end': 1730000000}
|
|
with app.test_client() as client:
|
|
resp = _post_webhook(client)
|
|
assert resp.status_code == 200
|
|
wh = WebhookEvent.query.filter_by(stripe_event_id='evt_audit_1').first()
|
|
assert wh is not None
|
|
assert wh.event_type == 'checkout.session.completed'
|
|
assert wh.stripe_subscription_id == 'sub_audit'
|
|
assert wh.stripe_customer_id == 'cus_audit'
|
|
finally:
|
|
db.session.rollback()
|
|
db.drop_all()
|
|
_clear_webhook_env()
|