import os import logging from datetime import datetime, timedelta, timezone from repositories.tenant_repository import tenant_repository logger = logging.getLogger(__name__) STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY') TIER_SEATS = { 'analyse': 5, 'discover': 8, 'intelligence': 10, 'enterprise': 999, # effectively unlimited } TRIAL_PERIODS = { 'analyse': 14, 'discover': 21, 'intelligence': 30, 'enterprise': 0, } ADDITIONAL_SEAT_PRICE = 45 # USD/seat/month — configured as a Stripe add-on price TIER_PRICE_ENV_VARS = { 'analyse': 'STRIPE_PRICE_ANALYSE', 'discover': 'STRIPE_PRICE_DISCOVER', 'intelligence': 'STRIPE_PRICE_INTELLIGENCE', # Enterprise is unlisted — created manually per client, no env var. } def _stripe(): """Lazily imports and configures the stripe SDK — avoids an import-time key requirement.""" import stripe stripe.api_key = STRIPE_SECRET_KEY return stripe def create_portal_session(tenant_id): """ Creates a Stripe customer portal session for self-service billing. Data flow: tenant_id → PocketBase stripe_customer_id → Stripe billing_portal.Session.create → portal URL returned """ tenant = tenant_repository.get_by_id(tenant_id) if not tenant or not tenant.get('stripe_customer_id'): raise ValueError('Tenant has no Stripe customer on file') stripe = _stripe() session = stripe.billing_portal.Session.create( customer=tenant['stripe_customer_id'], return_url='https://app.bettersight.io/account' ) return session.url def create_upgrade_checkout(tenant_id, target_tier): """ Creates a Stripe checkout session that upgrades a tenant to target_tier. Tags the session with tenant_id + target_tier in metadata so the webhook handler can distinguish an upgrade completion from a brand-new-tenant signup completion. Data flow: tenant_id + target_tier → STRIPE_PRICE_* env lookup for target_tier → Stripe checkout.Session.create (mode=subscription, metadata tagged) → checkout URL returned """ price_env_var = TIER_PRICE_ENV_VARS.get(target_tier) if not price_env_var or not os.getenv(price_env_var): raise ValueError(f'No Stripe price configured for tier: {target_tier}') tenant = tenant_repository.get_by_id(tenant_id) if not tenant: raise ValueError(f'Unknown tenant: {tenant_id}') # Stripe rejects an empty-string `customer` param outright (it only # accepts a real customer id or the param being omitted entirely) — # every trial/manually-onboarded tenant (CLAUDE.md §32) starts with # no Stripe customer at all, so this must be conditional. When # there's no customer yet, `customer_email` pre-fills checkout and # Stripe creates the customer during this session; the resulting # id is captured in handle_checkout_completed() below. checkout_kwargs = {} if tenant.get('stripe_customer_id'): checkout_kwargs['customer'] = tenant['stripe_customer_id'] elif tenant.get('email'): checkout_kwargs['customer_email'] = tenant['email'] stripe = _stripe() session = stripe.checkout.Session.create( mode='subscription', line_items=[{'price': os.getenv(price_env_var), 'quantity': 1}], payment_method_collection='if_required', subscription_data={'trial_period_days': TRIAL_PERIODS.get(target_tier, 0)}, metadata={'tenant_id': tenant_id, 'target_tier': target_tier}, success_url='https://app.bettersight.io/account?upgrade=success', cancel_url='https://app.bettersight.io/account?upgrade=cancelled', **checkout_kwargs, ) return session.url def handle_checkout_completed(event_data): """ Handles checkout.session.completed for both flows: - Upgrade flow (metadata.tenant_id present): bumps the existing tenant's tier and max_seats. - New signup flow (no metadata.tenant_id): out of scope for this handler — new-tenant creation from a public checkout flow is not part of the Analyse-tier MVP onboarding runbook (CLAUDE.md §32 onboards the first clients manually); this function only handles the upgrade case until self-serve signup is built. Data flow: Stripe checkout.session.completed event → metadata.tenant_id present? → yes: tenant tier/max_seats updated, status set to trial/active per whether a trial was attached → no: logged and ignored (manual onboarding handles new tenants) """ metadata = event_data.get('metadata', {}) tenant_id = metadata.get('tenant_id') target_tier = metadata.get('target_tier') if not tenant_id or not target_tier: logger.info('checkout.session.completed with no upgrade metadata — ignoring (manual onboarding flow)') return updates = { 'tier': target_tier, 'max_seats': TIER_SEATS.get(target_tier, 5), 'stripe_subscription_id': event_data.get('subscription'), 'status': 'trial' if TRIAL_PERIODS.get(target_tier, 0) > 0 else 'active', 'trial_ends_at': ( (datetime.now(timezone.utc) + timedelta(days=TRIAL_PERIODS[target_tier])).isoformat() if TRIAL_PERIODS.get(target_tier, 0) > 0 else None ), } # Captures the customer id Stripe created during this checkout for # tenants that had none yet — without this, the tenant would still # have no stripe_customer_id after their very first upgrade, and # both the next upgrade and the billing portal route would fail # the same way this one just did. if event_data.get('customer'): updates['stripe_customer_id'] = event_data['customer'] tenant_repository.update(tenant_id, updates) def handle_subscription_updated(event_data, tenant_id): """Updates tier/max_seats if the plan changed on an existing subscription.""" tier = event_data.get('metadata', {}).get('tier') updates = {} if tier and tier in TIER_SEATS: updates['tier'] = tier updates['max_seats'] = TIER_SEATS[tier] if updates: tenant_repository.update(tenant_id, updates) def handle_subscription_deleted(tenant_id): """Marks a tenant inactive on cancellation — seats block at next Apps Script cache expiry (6hrs).""" tenant_repository.update(tenant_id, {'status': 'inactive'}) def delete_tenant_data(tenant_id): """ GDPR Article 17 (Right to Erasure) — permanently deletes a tenant and every tenant-scoped collection, per CLAUDE.md §22. CLAUDE.md's spec says to "log to audit_logs" as a separate step, but no such collection exists anywhere in the schema — the closest real candidates (alerts, digest_logs) are themselves in the delete list. This logs the deletion via standard Python logging (visible in Sentry/server logs) instead of inventing new PocketBase schema for a single audit line; revisit if a real compliance audit trail is ever required. §18's "audit logs are append-only" note about alerts/digest_logs describes normal runtime retention — it doesn't override a tenant's explicit erasure request, and alerts contain no PII of their own (they describe competitor products, not the PM). Data flow: tenant_id → cancel Stripe subscription (side-effect, logged not raised) → delete every tenant-scoped collection row → delete the tenant record itself → send deletion confirmation email (side-effect) → logged """ from repositories.seat_repository import seat_repository from repositories.competitor_repository import competitor_repository from repositories.product_repository import product_repository from repositories.price_history_repository import price_history_repository from repositories.scrape_repository import scrape_repository from repositories.alert_repository import alert_repository from repositories.battlecard_repository import battlecard_repository from repositories.comparable_repository import comparable_repository from repositories.client_trips_repository import client_trips_repository from services import email_service tenant = tenant_repository.get_by_id(tenant_id) if not tenant: return False if tenant.get('stripe_subscription_id'): try: stripe = _stripe() stripe.Subscription.delete(tenant['stripe_subscription_id']) except Exception as e: logger.error(f'Stripe cancellation failed during account deletion: {str(e)}') for seat in seat_repository.list_for_tenant(tenant_id): seat_repository.delete(seat['id']) for product in product_repository.list_all_for_tenant(tenant_id): product_repository.delete(product['id']) for record in price_history_repository.list_for_tenant(tenant_id): price_history_repository.delete(record['id']) for run in scrape_repository.list_all_for_tenant(tenant_id): scrape_repository.delete(run['id']) for alert in alert_repository.list_recent(tenant_id, limit=1000): alert_repository.delete(alert['id']) for card in battlecard_repository.list_for_tenant(tenant_id): battlecard_repository.delete(card['id']) for match in comparable_repository.list_for_tenant(tenant_id, include_dismissed=True): comparable_repository.delete(match['id']) for trip in client_trips_repository.list_for_tenant(tenant_id): client_trips_repository.delete(trip['id']) # Competitors deleted last — nothing else references them by the # time this runs, and hard-deleting here (unlike the soft-deactivate # used by normal /account/competitors/ removal) is correct only # because the whole tenant is being erased. for competitor in competitor_repository.list_for_tenant(tenant_id, active_only=False): competitor_repository.delete(competitor['id']) admin_email = tenant.get('email') tenant_name = tenant.get('name') tenant_repository.delete(tenant_id) # Erasure is already complete by this point — a failure sending the # confirmation is a side effect only, never allowed to surface as a # failure of the deletion itself. if admin_email: try: email_service.send_account_deleted_email(admin_email, tenant_name) except Exception as e: logger.error(f'Deletion confirmation email failed for tenant {tenant_id}: {str(e)}') logger.info(f'GDPR erasure completed for tenant {tenant_id} ({tenant_name})') return True