feat(billing): B-2.7 Stripe Checkout 3 plans CAD/TVQ + Apple/Google Pay

Adds the customer-facing checkout flow under /checkout/<plan>:
- src/billing/plans.py — Plan dataclass + 3 plans (DictIA 8 / 16 / Cloud),
  monthly + yearly Price IDs resolved from STRIPE_DICTIA_*_{SETUP,MONTHLY,YEARLY} env.
- src/billing/stripe_client.py — lazy stripe.api_key init, get_or_create_customer
  (persists user.stripe_customer_id), create_checkout_session with mode=subscription,
  currency=cad, automatic_tax=true (TPS 5% + TVQ 9.975%), billing_address_collection,
  metadata on both Session and Subscription for the B-2.8 webhook.
- src/billing/routes.py — GET /checkout/<plan>?period=monthly|yearly returns 303
  redirect to Stripe-hosted Checkout. Friendly French flash + redirect to /tarifs
  on unknown plan, missing STRIPE_SECRET_KEY, missing Price IDs, or Stripe API error.
  GET /checkout/success and /checkout/cancel render brand-tokenized templates that
  extend marketing/base.html.
- templates/billing/{success,cancel}.html — explicit "activé sous quelques minutes"
  note (webhook is async), aucun montant prélevé reassurance on cancel.
- config/env.stripe.example — env vars + Stripe Dashboard setup checklist
  (CAD activation, Stripe Tax registrations, Apple/Google Pay enable, webhook).
- tests/test_stripe_checkout.py — 25 tests covering plans, stripe_client, routes,
  and the _PUBLIC_INDEXABLE_ENDPOINTS integration. Stripe SDK mocked via
  unittest.mock.patch (no network). Windows manual driver included.

Webhook (B-2.8) will be the source of truth for user.subscription_status.
This task only mutates user.stripe_customer_id (identity, not state).
Existing pricing CTAs in templates/marketing/_partials/_pricing_tiers.html
already link to /checkout/<slug> (verified) — no marketing template touched.

Tests: 25/25 new + 89/89 prior pass on Windows manual driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 08:26:13 -04:00
parent b8fa321edd
commit f1a5ad565f
9 changed files with 1239 additions and 1 deletions

97
src/billing/plans.py Normal file
View File

@@ -0,0 +1,97 @@
"""DictIA pricing plans (B-2.7).
Centralized plan registry. Stripe Price IDs are resolved from environment
variables — set STRIPE_<PLAN>_<PERIOD> env vars in production. The slug
(`dictia-8`, `dictia-16`, `dictia-cloud`) is the canonical identifier
used throughout the codebase (URL params, webhook metadata, audit logs).
Pricing reference (CAD, pre-tax — TPS/TVQ added by Stripe automatic_tax):
- DictIA 8: 3 450$ setup (one-time) + 173$/mo recurring (or yearly = 173 × 12 × 0.85)
- DictIA 16: 5 750$ setup (one-time) + 201$/mo recurring (or yearly = 201 × 12 × 0.85)
- DictIA Cloud: 369$/mo recurring (or yearly = 369 × 12 × 0.85)
"""
import os
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass(frozen=True)
class Plan:
"""A DictIA subscription plan.
Stripe Price IDs are resolved lazily from environment variables — the
Plan instance itself only stores the variable names. This lets the
application boot without Stripe credentials (CI, dev branches) and
keeps secrets out of source control.
"""
slug: str
name: str
description_fr: str
has_setup_fee: bool
monthly_env: str
yearly_env: str
setup_env: Optional[str] = None # only set for plans with a setup fee
def setup_price_id(self) -> Optional[str]:
if not self.has_setup_fee or not self.setup_env:
return None
return os.environ.get(self.setup_env)
def monthly_price_id(self) -> Optional[str]:
return os.environ.get(self.monthly_env)
def yearly_price_id(self) -> Optional[str]:
return os.environ.get(self.yearly_env)
def is_configured(self) -> bool:
"""True when all required Stripe Price IDs are set in the environment."""
if self.has_setup_fee and not self.setup_price_id():
return False
return bool(self.monthly_price_id() and self.yearly_price_id())
def price_id_for_period(self, period: str) -> Optional[str]:
return self.yearly_price_id() if period == 'yearly' else self.monthly_price_id()
PLANS: Dict[str, Plan] = {
'dictia-8': Plan(
slug='dictia-8',
name='DictIA 8',
description_fr='Boîtier 8 canaux + transcription IA locale (poste de travail).',
has_setup_fee=True,
setup_env='STRIPE_DICTIA_8_SETUP',
monthly_env='STRIPE_DICTIA_8_MONTHLY',
yearly_env='STRIPE_DICTIA_8_YEARLY',
),
'dictia-16': Plan(
slug='dictia-16',
name='DictIA 16',
description_fr='Boîtier 16 canaux + transcription IA locale (salle de réunion).',
has_setup_fee=True,
setup_env='STRIPE_DICTIA_16_SETUP',
monthly_env='STRIPE_DICTIA_16_MONTHLY',
yearly_env='STRIPE_DICTIA_16_YEARLY',
),
'dictia-cloud': Plan(
slug='dictia-cloud',
name='DictIA Cloud',
description_fr='Transcription IA hébergée au Québec, 100% conforme Loi 25.',
has_setup_fee=False,
monthly_env='STRIPE_DICTIA_CLOUD_MONTHLY',
yearly_env='STRIPE_DICTIA_CLOUD_YEARLY',
),
}
VALID_PERIODS = ('monthly', 'yearly')
def get_plan(slug: str) -> Optional[Plan]:
"""Return the Plan for `slug`, or None if unknown."""
if not slug:
return None
return PLANS.get(slug)
def list_plans() -> List[Plan]:
"""Return all registered plans in registration order."""
return list(PLANS.values())