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:
97
src/billing/plans.py
Normal file
97
src/billing/plans.py
Normal 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())
|
||||
Reference in New Issue
Block a user