"""Stripe SDK client wrapper (B-2.7). Lazy-initializes stripe.api_key from STRIPE_SECRET_KEY at first use, so the app can boot without Stripe credentials (CI, dev, contributor branches). Raises StripeNotConfiguredError if a Stripe API call is attempted without the key set. This module is intentionally thin: it owns the stripe.* call surface used by B-2.7 (Checkout) and is reused by B-2.8 (webhook signature verification). No subscription state is persisted here — the webhook is the source of truth for `user.subscription_status`. The only User mutation is `stripe_customer_id` (identity, not state). """ import os from typing import List import stripe class StripeNotConfiguredError(RuntimeError): """Raised when STRIPE_SECRET_KEY (or a plan Price ID) is missing at call time.""" def is_stripe_configured() -> bool: """Return True if STRIPE_SECRET_KEY is set in the environment.""" return bool(os.environ.get('STRIPE_SECRET_KEY')) def _ensure_configured() -> None: """Lazy-initialize stripe.api_key. Raises if STRIPE_SECRET_KEY is missing.""" if not is_stripe_configured(): raise StripeNotConfiguredError( 'STRIPE_SECRET_KEY is not set. Configure it before using billing.' ) if not stripe.api_key: stripe.api_key = os.environ['STRIPE_SECRET_KEY'] def get_or_create_customer(user) -> str: """Return the Stripe customer ID for `user`, creating one if needed. Persists the Stripe customer ID on user.stripe_customer_id so subsequent checkouts (and the webhook) can correlate Stripe events back to the user. """ from src.database import db _ensure_configured() if user.stripe_customer_id: return user.stripe_customer_id customer = stripe.Customer.create( email=user.email, name=(user.name or user.username), metadata={ 'dictia_user_id': str(user.id), 'dictia_username': user.username, }, ) user.stripe_customer_id = customer.id db.session.commit() return customer.id def create_checkout_session( plan_slug: str, period: str, user, success_url: str, cancel_url: str, ): """Create a Stripe Checkout Session for the given plan + period. Configuration applied: - mode='subscription' (recurring) - currency='cad' - automatic_tax.enabled=true (Stripe applies TPS 5% + TVQ 9.975%) - billing_address_collection='required' (needed for Tax) - allow_promotion_codes=true - Apple/Google Pay are auto-enabled for card payments in Stripe Dashboard - Hardware plans (8/16) include a one-time setup line item AND the recurring subscription line item. The success_url is decorated with `?session_id={CHECKOUT_SESSION_ID}` so the success page can optionally surface the session id (analytics). """ from src.billing.plans import VALID_PERIODS, get_plan _ensure_configured() plan = get_plan(plan_slug) if plan is None: raise ValueError(f'Unknown plan: {plan_slug!r}') if period not in VALID_PERIODS: raise ValueError( f'Invalid period: {period!r} (expected one of {VALID_PERIODS})' ) if not plan.is_configured(): raise StripeNotConfiguredError( f'Stripe Price IDs for {plan_slug!r} are not set in environment.' ) customer_id = get_or_create_customer(user) line_items: List[dict] = [] # One-time setup fee for hardware plans (DictIA 8 / DictIA 16) if plan.has_setup_fee: setup_id = plan.setup_price_id() if setup_id: line_items.append({'price': setup_id, 'quantity': 1}) # Recurring subscription line_items.append({ 'price': plan.price_id_for_period(period), 'quantity': 1, }) # Inject CHECKOUT_SESSION_ID placeholder while preserving any existing query string decorated_success_url = success_url + ( '&' if '?' in success_url else '?' ) + 'session_id={CHECKOUT_SESSION_ID}' metadata = { 'dictia_user_id': str(user.id), 'dictia_plan_slug': plan_slug, 'dictia_period': period, } return stripe.checkout.Session.create( mode='subscription', customer=customer_id, line_items=line_items, success_url=decorated_success_url, cancel_url=cancel_url, automatic_tax={'enabled': True}, currency='cad', billing_address_collection='required', customer_update={'address': 'auto', 'name': 'auto'}, allow_promotion_codes=True, metadata=metadata, # Webhook (B-2.8) reads metadata off the subscription, not the session subscription_data={'metadata': metadata}, )