app.bettersight.io/backend/services/billing_service.py

147 lines
5.2 KiB
Python

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 = _stripe()
session = stripe.checkout.Session.create(
customer=tenant.get('stripe_customer_id'),
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',
)
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
tenant_repository.update(tenant_id, {
'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
),
})
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'})