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>
239 lines
9.3 KiB
Python
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
|
|
)
|