Files
turf_saas/billing_db.py
CTO H3R7Tech 0e25ec54d1 feat(HRT-202): Billing tables + consumption endpoint
Phase 1 — Added 3 SQLite tables to billing_db.py:
- invoices (invoice_number, user_id, period, amount, status, pdf_path)
- transactions (user_id, invoice_id, type, amount, stripe_payment_intent)
- consumption_log (user_id, date, api_calls, endpoint)
- PRAGMA foreign_keys = ON in get_db()
- Dataclass model classes for documentation

Phase 2 — GET /api/v1/billing/consumption?month=YYYY-MM:
- JWT auth required, user can only query own data
- YYYY-MM validation (422 on malformed)
- Configurable PLAN_LIMITS via env vars (not hardcoded)
- Monthly aggregation from consumption_log
- Alert semantics: 80% soft (X-Billing-Alert: soft_limit_warning)
                  100% hard (X-Billing-Alert: hard_limit_reached)
- Proper error handling (200 with zeros for no data)

Pre-checks addressed:
- PRAGMA foreign_keys = ON added to get_db()
- saas_subscriptions.plan column verified present
- Invoice format: FACT-{YYYYMM}-{XXXX} (future generation)
- Dataclass models added

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 11:42:36 +02:00

239 lines
9.3 KiB
Python

#!/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
)