"""Billing routes — Stripe Checkout (B-2.7). URL space (prefix `/checkout`, set on billing_bp): - GET /checkout/?period=monthly|yearly → 303 redirect to Stripe-hosted Checkout - GET /checkout/success?session_id=... → confirmation page (async activation note) - GET /checkout/cancel → friendly "no charge made" page The webhook route (B-2.8) is registered separately at /webhooks/stripe outside the /checkout prefix and is CSRF-exempt. """ import logging from flask import ( Blueprint, current_app, flash, redirect, render_template, request, url_for, ) from flask_login import current_user, login_required from src.billing import billing_bp from src.billing.plans import VALID_PERIODS, get_plan from src.billing.stripe_client import ( StripeNotConfiguredError, create_checkout_session, is_stripe_configured, ) logger = logging.getLogger(__name__) @billing_bp.route('/') @login_required def checkout(plan): """Initiate Stripe Checkout for the given plan + period. Redirects to /tarifs with a French flash on any error (unknown plan, Stripe not configured, plan Price IDs missing, Stripe API failure). Returns a 303 See Other redirect to the Stripe-hosted Checkout on success (303 is what Stripe documents for HTTP redirects to checkout.stripe.com). """ plan_obj = get_plan(plan) if plan_obj is None: flash('Forfait inconnu.', 'danger') return redirect(url_for('marketing.tarifs')) period = request.args.get('period', 'monthly') if period not in VALID_PERIODS: period = 'monthly' if not is_stripe_configured(): flash( "Le paiement en ligne n'est pas disponible pour le moment. " "Contactez info@dictia.ca pour finaliser votre abonnement.", 'warning', ) return redirect(url_for('marketing.tarifs')) if not plan_obj.is_configured(): flash( "Ce forfait n'est pas encore configuré. Contactez info@dictia.ca.", 'warning', ) return redirect(url_for('marketing.tarifs')) success_url = url_for('billing.success', _external=True) cancel_url = url_for('billing.cancel', _external=True) try: session = create_checkout_session( plan_slug=plan, period=period, user=current_user, success_url=success_url, cancel_url=cancel_url, ) except StripeNotConfiguredError as e: logger.error('Stripe not configured at checkout: %s', e) flash( "Le paiement en ligne n'est pas disponible. " "Contactez info@dictia.ca.", 'warning', ) return redirect(url_for('marketing.tarifs')) except ValueError as e: logger.warning('Invalid checkout request: %s', e) flash('Demande de paiement invalide.', 'danger') return redirect(url_for('marketing.tarifs')) except Exception as e: # noqa: BLE001 logger.exception( 'Stripe Checkout creation failed for user %s plan %s: %s', getattr(current_user, 'id', '?'), plan, e, ) flash( "Une erreur est survenue lors de l'ouverture du paiement. " "Réessayez ou contactez info@dictia.ca.", 'danger', ) return redirect(url_for('marketing.tarifs')) # Stripe documents 303 See Other for hosted-Checkout redirects. return redirect(session.url, code=303) @billing_bp.route('/success') def success(): """Post-payment confirmation page. The session_id query param is preserved for optional client-side analytics but is NOT trusted server-side — Stripe's webhook (B-2.8) is the source of truth for subscription state. This page makes that asynchrony explicit ("Votre abonnement sera activé sous quelques minutes."). """ session_id = request.args.get('session_id') return render_template( 'billing/success.html', title='Paiement confirmé — DictIA', session_id=session_id, ) @billing_bp.route('/cancel') def cancel(): """User cancelled the Stripe Checkout. No state to revert; no charge made.""" return render_template( 'billing/cancel.html', title='Paiement annulé — DictIA', )