264 lines
10 KiB
Python
264 lines
10 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 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/<id> 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
|