#!/usr/bin/env python3 """ DB Migration — Billing Stripe Sprint 5-6: HRT-31 Adds stripe_subscription_id and status columns to subscriptions table, and an invoices / grace-period tracking table. Run once: ./venv/bin/python billing_db.py """ import sqlite3 import os import logging from dataclasses import dataclass from typing import Optional DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") logger = logging.getLogger("turf_saas.billing_db") def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def migrate_billing_tables(): """Idempotent migration: add billing columns and billing_events table. Requires auth tables (users, subscriptions) to exist first. Calls init_auth_tables() automatically if subscriptions is absent. """ from auth_db import init_auth_tables as _init_auth conn = get_db() c = conn.cursor() # Ensure base auth tables exist tables = { row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'") } conn.close() if "subscriptions" not in tables: _init_auth() conn = get_db() c = conn.cursor() # Add stripe_subscription_id if missing columns = {row[1] for row in c.execute("PRAGMA table_info(subscriptions)")} if "stripe_subscription_id" not in columns: c.execute("ALTER TABLE subscriptions ADD COLUMN stripe_subscription_id TEXT") logger.info("[billing_db] Added stripe_subscription_id column to subscriptions") if "status" not in columns: c.execute( "ALTER TABLE subscriptions ADD COLUMN " "status TEXT NOT NULL DEFAULT 'active' " "CHECK(status IN ('active','past_due','canceled','trialing','incomplete'))" ) logger.info("[billing_db] Added status column to subscriptions") if "grace_period_end" not in columns: c.execute("ALTER TABLE subscriptions ADD COLUMN grace_period_end DATETIME") logger.info("[billing_db] Added grace_period_end column to subscriptions") if "current_period_end" not in columns: c.execute("ALTER TABLE subscriptions ADD COLUMN current_period_end DATETIME") logger.info("[billing_db] Added current_period_end column to subscriptions") # billing_events table — audit trail for all webhook events c.executescript(""" CREATE TABLE IF NOT EXISTS billing_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, stripe_event_id TEXT NOT NULL UNIQUE, event_type TEXT NOT NULL, user_id TEXT, payload TEXT, processed_at DATETIME NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS saas_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free', start_date DATETIME DEFAULT (datetime('now')), end_date DATETIME, stripe_customer_id TEXT, stripe_subscription_id TEXT, status TEXT NOT NULL DEFAULT 'active', grace_period_end DATETIME, current_period_end DATETIME ); CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id); CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type); CREATE INDEX IF NOT EXISTS idx_saas_subs_user ON saas_subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id); CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id); -- HRT-202: Billing tables (invoices, transactions, consumption_log) CREATE TABLE IF NOT EXISTS invoices ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_number TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users(id), period_start TEXT NOT NULL, period_end TEXT NOT NULL, plan TEXT NOT NULL, amount_cents INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'EUR', status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','overdue','cancelled','refunded')), pdf_path TEXT, stripe_invoice_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), paid_at TEXT ); CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), invoice_id INTEGER REFERENCES invoices(id), type TEXT NOT NULL CHECK(type IN ('subscription','overage','credit','refund')), amount_cents INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'EUR', stripe_payment_intent_id TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS consumption_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), date TEXT NOT NULL, api_calls INTEGER NOT NULL DEFAULT 0, endpoint TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, date, endpoint) ); CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id); CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id); CREATE INDEX IF NOT EXISTS idx_consumption_user ON consumption_log(user_id); CREATE INDEX IF NOT EXISTS idx_consumption_date ON consumption_log(date); """) conn.commit() conn.close() print( "[billing_db] Migration complete: subscriptions + billing_events + invoices + transactions + consumption_log ready." ) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) migrate_billing_tables() # ────────────────────────────────────────────────────────────── # Model classes (documentation / type hints) # ────────────────────────────────────────────────────────────── @dataclass class Invoice: id: Optional[int] = None invoice_number: str = "" user_id: int = 0 period_start: str = "" period_end: str = "" plan: str = "" amount_cents: int = 0 currency: str = "EUR" status: str = "pending" pdf_path: Optional[str] = None stripe_invoice_id: Optional[str] = None created_at: str = "" paid_at: Optional[str] = None @dataclass class Transaction: id: Optional[int] = None user_id: int = 0 invoice_id: Optional[int] = None type: str = "subscription" amount_cents: int = 0 currency: str = "EUR" stripe_payment_intent_id: Optional[str] = None description: Optional[str] = None created_at: str = "" @dataclass class ConsumptionLog: id: Optional[int] = None user_id: int = 0 date: str = "" api_calls: int = 0 endpoint: Optional[str] = None created_at: str = "" # ────────────────────────────────────────────────────────────── # Re-exported helpers for test usage # (primary implementations live in api_v1/routes/billing.py) # ────────────────────────────────────────────────────────────── def _upsert_subscription(db, user_id: int, **fields): """ Update existing subscription row or insert a new one. Convenience re-export for test helpers. """ existing = db.execute( "SELECT id FROM subscriptions WHERE user_id = ? ORDER BY start_date DESC LIMIT 1", (user_id,), ).fetchone() if existing: set_parts = ", ".join(f"{k} = ?" for k in fields) values = list(fields.values()) + [existing["id"]] db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values) else: cols = ", ".join(["user_id"] + list(fields.keys())) placeholders = ", ".join(["?"] * (1 + len(fields))) values = [user_id] + list(fields.values()) db.execute( f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values )