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>
798 lines
28 KiB
Python
798 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Billing Blueprint — Stripe integration
|
|
Sprint 5-6: HRT-31
|
|
|
|
Endpoints:
|
|
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
|
|
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
|
|
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
|
|
GET /api/v1/billing/status — current subscription status (auth required)
|
|
|
|
Environment variables required:
|
|
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
|
|
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
|
|
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
|
|
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
|
|
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
|
|
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import stripe
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from saas_auth import require_auth as jwt_required_middleware
|
|
from billing_db import get_db, migrate_billing_tables
|
|
|
|
logger = logging.getLogger("turf_saas.billing")
|
|
|
|
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Stripe configuration
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
|
|
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
|
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
|
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
|
|
|
|
# Plan → Stripe Price ID mapping
|
|
PLAN_PRICE_IDS = {
|
|
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
|
|
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
|
|
}
|
|
|
|
# Plan display names
|
|
PLAN_NAMES = {
|
|
"free": "Free",
|
|
"premium": "Premium",
|
|
"pro": "Pro",
|
|
}
|
|
|
|
# Plan consumption limits (configurable, not hardcoded)
|
|
# Override via env vars: BILLING_LIMIT_FREE_API_CALLS, BILLING_LIMIT_PREMIUM_API_CALLS, etc.
|
|
PLAN_LIMITS = {
|
|
"free": {
|
|
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_FREE_API_CALLS", "300")),
|
|
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_FREE_TOKENS", "100000")),
|
|
},
|
|
"premium": {
|
|
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PREMIUM_API_CALLS", "3000")),
|
|
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PREMIUM_TOKENS", "1000000")),
|
|
},
|
|
"pro": {
|
|
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PRO_API_CALLS", "30000")),
|
|
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PRO_TOKENS", "10000000")),
|
|
},
|
|
}
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# DB helpers
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _sget(obj, key, default=None):
|
|
"""Safely get a value from a dict OR a Stripe StripeObject.
|
|
|
|
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
|
|
"""
|
|
try:
|
|
# StripeObject supports [] but not .get(); dict supports both
|
|
val = obj[key]
|
|
return val if val is not None else default
|
|
except (KeyError, TypeError):
|
|
return default
|
|
|
|
|
|
def _get_active_subscription(db, user_id):
|
|
"""Return the most recent active subscription row for a user."""
|
|
return db.execute(
|
|
"""SELECT * FROM saas_subscriptions
|
|
WHERE user_id = ?
|
|
ORDER BY start_date DESC
|
|
LIMIT 1""",
|
|
(str(user_id),),
|
|
).fetchone()
|
|
|
|
|
|
def _upsert_subscription(db, user_id, **fields):
|
|
"""
|
|
Update existing subscription or insert a new one.
|
|
fields: plan, stripe_customer_id, stripe_subscription_id,
|
|
status, current_period_end, grace_period_end, end_date
|
|
"""
|
|
existing = _get_active_subscription(db, user_id)
|
|
if existing:
|
|
# Build SET clause dynamically from provided fields
|
|
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
|
values = list(fields.values()) + [existing["id"]]
|
|
db.execute(f"UPDATE saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
|
else:
|
|
cols = ", ".join(["user_id"] + list(fields.keys()))
|
|
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
|
values = [str(user_id)] + list(fields.values())
|
|
db.execute(
|
|
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
|
)
|
|
|
|
|
|
def _update_user_plan(db, user_id, plan: str):
|
|
"""Sync saas_users.plan field to match active subscription."""
|
|
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(user_id)))
|
|
|
|
|
|
def _get_or_create_stripe_customer(user, db) -> str:
|
|
"""Return existing stripe_customer_id or create a new Stripe Customer."""
|
|
sub = _get_active_subscription(db, user["id"])
|
|
if sub and sub["stripe_customer_id"]:
|
|
return sub["stripe_customer_id"]
|
|
|
|
# Create new customer in Stripe
|
|
customer = stripe.Customer.create(
|
|
email=user["email"],
|
|
metadata={"user_id": str(user["id"])},
|
|
)
|
|
return customer["id"]
|
|
|
|
|
|
def _record_billing_event(
|
|
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
|
|
):
|
|
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
|
|
try:
|
|
db.execute(
|
|
"""INSERT OR IGNORE INTO billing_events
|
|
(stripe_event_id, event_type, user_id, payload)
|
|
VALUES (?, ?, ?, ?)""",
|
|
(
|
|
stripe_event_id,
|
|
event_type,
|
|
user_id,
|
|
json.dumps(payload) if payload else None,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# POST /api/v1/billing/checkout
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@billing_bp.route("/checkout", methods=["POST"])
|
|
@jwt_required_middleware
|
|
def create_checkout():
|
|
"""
|
|
Create a Stripe Checkout session for upgrading to Premium or Pro.
|
|
---
|
|
tags:
|
|
- Billing
|
|
security:
|
|
- Bearer: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [plan]
|
|
properties:
|
|
plan:
|
|
type: string
|
|
enum: [premium, pro]
|
|
responses:
|
|
200:
|
|
description: Checkout session URL
|
|
schema:
|
|
type: object
|
|
properties:
|
|
checkout_url:
|
|
type: string
|
|
session_id:
|
|
type: string
|
|
400:
|
|
description: Invalid plan or Stripe not configured
|
|
503:
|
|
description: Stripe API error
|
|
"""
|
|
if not stripe.api_key:
|
|
return jsonify({"error": "Stripe non configuré"}), 503
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
plan = body.get("plan", "").lower()
|
|
|
|
if plan not in ("premium", "pro"):
|
|
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
|
|
|
|
price_id = PLAN_PRICE_IDS.get(plan)
|
|
if not price_id:
|
|
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
|
|
|
user = request.current_user
|
|
if user["plan"] == plan:
|
|
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
|
|
|
db = get_db()
|
|
try:
|
|
customer_id = _get_or_create_stripe_customer(user, db)
|
|
# Persist customer_id early to prevent duplicates
|
|
_upsert_subscription(
|
|
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
|
|
)
|
|
db.commit()
|
|
|
|
session = stripe.checkout.Session.create(
|
|
customer=customer_id,
|
|
payment_method_types=["card"],
|
|
line_items=[{"price": price_id, "quantity": 1}],
|
|
mode="subscription",
|
|
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
|
|
cancel_url=f"{APP_BASE_URL}/billing/cancel",
|
|
metadata={"user_id": str(user["id"]), "plan": plan},
|
|
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
|
|
)
|
|
except stripe.StripeError as e:
|
|
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
|
|
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
|
finally:
|
|
db.close()
|
|
|
|
return jsonify(
|
|
{
|
|
"checkout_url": session.url,
|
|
"session_id": session.id,
|
|
"plan": plan,
|
|
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
|
}
|
|
), 200
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# POST /api/v1/billing/portal
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@billing_bp.route("/portal", methods=["POST"])
|
|
@jwt_required_middleware
|
|
def create_portal():
|
|
"""
|
|
Create a Stripe Customer Portal session for managing subscription.
|
|
---
|
|
tags:
|
|
- Billing
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Portal session URL
|
|
400:
|
|
description: No Stripe customer found
|
|
503:
|
|
description: Stripe not configured or API error
|
|
"""
|
|
if not stripe.api_key:
|
|
return jsonify({"error": "Stripe non configuré"}), 503
|
|
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
sub = _get_active_subscription(db, user["id"])
|
|
customer_id = sub["stripe_customer_id"] if sub else None
|
|
|
|
if not customer_id:
|
|
return jsonify(
|
|
{
|
|
"error": "Aucun abonnement Stripe trouvé. "
|
|
"Souscrivez d'abord à un plan payant."
|
|
}
|
|
), 400
|
|
|
|
session = stripe.billing_portal.Session.create(
|
|
customer=customer_id,
|
|
return_url=f"{APP_BASE_URL}/account",
|
|
)
|
|
except stripe.StripeError as e:
|
|
logger.error("Stripe portal error for user %s: %s", user["id"], e)
|
|
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
|
finally:
|
|
db.close()
|
|
|
|
return jsonify({"portal_url": session.url}), 200
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/billing/status
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@billing_bp.route("/status", methods=["GET"])
|
|
@jwt_required_middleware
|
|
def billing_status():
|
|
"""
|
|
Return current subscription status for the authenticated user.
|
|
---
|
|
tags:
|
|
- Billing
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Subscription status
|
|
"""
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
sub = _get_active_subscription(db, user["id"])
|
|
finally:
|
|
db.close()
|
|
|
|
if not sub:
|
|
return jsonify(
|
|
{
|
|
"plan": "free",
|
|
"status": "active",
|
|
"stripe_customer_id": None,
|
|
"stripe_subscription_id": None,
|
|
"current_period_end": None,
|
|
"grace_period_end": None,
|
|
}
|
|
), 200
|
|
|
|
return jsonify(
|
|
{
|
|
"plan": sub["plan"],
|
|
"status": sub["status"] or "active",
|
|
"stripe_customer_id": sub["stripe_customer_id"],
|
|
"stripe_subscription_id": sub["stripe_subscription_id"],
|
|
"start_date": sub["start_date"],
|
|
"end_date": sub["end_date"],
|
|
"current_period_end": sub["current_period_end"],
|
|
"grace_period_end": sub["grace_period_end"],
|
|
}
|
|
), 200
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# POST /api/v1/billing/webhook
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@billing_bp.route("/webhook", methods=["POST"])
|
|
def stripe_webhook():
|
|
"""
|
|
Stripe webhook handler — no auth, signature-verified.
|
|
|
|
Handled events:
|
|
checkout.session.completed → activate subscription
|
|
customer.subscription.updated → sync plan/status
|
|
customer.subscription.deleted → downgrade to free
|
|
invoice.payment_failed → set past_due + 3-day grace period
|
|
invoice.payment_succeeded → clear grace period
|
|
"""
|
|
payload = request.get_data()
|
|
sig_header = request.headers.get("Stripe-Signature", "")
|
|
|
|
# Verify webhook signature (required in production)
|
|
if STRIPE_WEBHOOK_SECRET:
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
|
)
|
|
except stripe.SignatureVerificationError as e:
|
|
logger.warning("Stripe webhook signature invalid: %s", e)
|
|
return jsonify({"error": "Signature invalide"}), 400
|
|
except ValueError as e:
|
|
logger.warning("Stripe webhook payload invalid: %s", e)
|
|
return jsonify({"error": "Payload invalide"}), 400
|
|
else:
|
|
# Dev/test: accept without verification (log a warning)
|
|
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
|
|
try:
|
|
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
|
|
except Exception as e:
|
|
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
|
|
|
|
event_type = event["type"]
|
|
event_id = event["id"]
|
|
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
|
|
|
|
db = get_db()
|
|
try:
|
|
if event_type == "checkout.session.completed":
|
|
_handle_checkout_completed(db, event)
|
|
|
|
elif event_type in (
|
|
"customer.subscription.updated",
|
|
"customer.subscription.created",
|
|
):
|
|
_handle_subscription_updated(db, event)
|
|
|
|
elif event_type == "customer.subscription.deleted":
|
|
_handle_subscription_deleted(db, event)
|
|
|
|
elif event_type == "invoice.payment_failed":
|
|
_handle_payment_failed(db, event)
|
|
|
|
elif event_type == "invoice.payment_succeeded":
|
|
_handle_payment_succeeded(db, event)
|
|
|
|
else:
|
|
logger.debug("Unhandled Stripe event type: %s", event_type)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
return jsonify({"status": "ok"}), 200
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Webhook handlers
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _resolve_user_from_customer(db, customer_id: str):
|
|
"""Look up user_id via subscriptions.stripe_customer_id."""
|
|
row = db.execute(
|
|
"SELECT user_id FROM saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
|
(customer_id,),
|
|
).fetchone()
|
|
if row:
|
|
return row["user_id"]
|
|
|
|
# Fallback: query Stripe for user_id metadata
|
|
try:
|
|
customer = stripe.Customer.retrieve(customer_id)
|
|
meta = _sget(customer, "metadata") or {}
|
|
uid = _sget(meta, "user_id")
|
|
if uid:
|
|
return int(uid)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _resolve_plan_from_price(price_id: str) -> str:
|
|
"""Map Stripe price ID to internal plan name."""
|
|
for plan, pid in PLAN_PRICE_IDS.items():
|
|
if pid and pid == price_id:
|
|
return plan
|
|
# Unknown price — default to premium (safer than pro)
|
|
return "premium"
|
|
|
|
|
|
def _handle_checkout_completed(db, event):
|
|
"""checkout.session.completed → activate subscription for the user."""
|
|
session = event["data"]["object"]
|
|
customer_id = _sget(session, "customer")
|
|
subscription_id = _sget(session, "subscription")
|
|
metadata = _sget(session, "metadata") or {}
|
|
plan = _sget(metadata, "plan") or "premium"
|
|
user_id = _sget(metadata, "user_id")
|
|
|
|
if user_id:
|
|
user_id = str(user_id)
|
|
else:
|
|
user_id = _resolve_user_from_customer(db, customer_id)
|
|
|
|
if not user_id:
|
|
logger.error(
|
|
"checkout.session.completed: cannot resolve user for customer %s",
|
|
customer_id,
|
|
)
|
|
return
|
|
|
|
# Fetch subscription details from Stripe
|
|
current_period_end = None
|
|
if subscription_id:
|
|
try:
|
|
sub = stripe.Subscription.retrieve(subscription_id)
|
|
current_period_end = datetime.fromtimestamp(
|
|
sub["current_period_end"], tz=timezone.utc
|
|
).isoformat()
|
|
# Sync plan from price if metadata plan is missing
|
|
if sub["items"]["data"]:
|
|
price_id = sub["items"]["data"][0]["price"]["id"]
|
|
plan = _resolve_plan_from_price(price_id)
|
|
except Exception as e:
|
|
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
|
|
|
|
_upsert_subscription(
|
|
db,
|
|
user_id,
|
|
plan=plan,
|
|
stripe_customer_id=customer_id,
|
|
stripe_subscription_id=subscription_id,
|
|
status="active",
|
|
current_period_end=current_period_end,
|
|
grace_period_end=None,
|
|
)
|
|
_update_user_plan(db, user_id, plan)
|
|
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
|
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
|
|
|
|
|
|
def _handle_subscription_updated(db, event):
|
|
"""customer.subscription.updated → sync status and plan."""
|
|
sub_obj = event["data"]["object"]
|
|
customer_id = _sget(sub_obj, "customer")
|
|
subscription_id = _sget(sub_obj, "id")
|
|
stripe_status = _sget(sub_obj, "status") or "active"
|
|
current_period_end = None
|
|
|
|
cpe = _sget(sub_obj, "current_period_end")
|
|
if cpe:
|
|
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
|
|
|
|
# Resolve plan from price
|
|
plan = "premium"
|
|
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
|
|
if items_data:
|
|
price_id = items_data[0]["price"]["id"]
|
|
plan = _resolve_plan_from_price(price_id)
|
|
|
|
user_id = _resolve_user_from_customer(db, customer_id)
|
|
if not user_id:
|
|
# Try metadata
|
|
meta = _sget(sub_obj, "metadata") or {}
|
|
meta_uid = _sget(meta, "user_id")
|
|
if meta_uid:
|
|
user_id = str(meta_uid)
|
|
|
|
if not user_id:
|
|
logger.error(
|
|
"subscription.updated: cannot resolve user for customer %s", customer_id
|
|
)
|
|
return
|
|
|
|
_upsert_subscription(
|
|
db,
|
|
user_id,
|
|
plan=plan,
|
|
stripe_customer_id=customer_id,
|
|
stripe_subscription_id=subscription_id,
|
|
status=stripe_status,
|
|
current_period_end=current_period_end,
|
|
)
|
|
_update_user_plan(db, user_id, plan)
|
|
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
|
logger.info(
|
|
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
|
|
)
|
|
|
|
|
|
def _handle_subscription_deleted(db, event):
|
|
"""customer.subscription.deleted → downgrade to free."""
|
|
sub_obj = event["data"]["object"]
|
|
customer_id = _sget(sub_obj, "customer")
|
|
|
|
user_id = _resolve_user_from_customer(db, customer_id)
|
|
if not user_id:
|
|
meta = _sget(sub_obj, "metadata") or {}
|
|
meta_uid = _sget(meta, "user_id")
|
|
if meta_uid:
|
|
user_id = str(meta_uid)
|
|
|
|
if not user_id:
|
|
logger.error(
|
|
"subscription.deleted: cannot resolve user for customer %s", customer_id
|
|
)
|
|
return
|
|
|
|
_upsert_subscription(
|
|
db,
|
|
user_id,
|
|
plan="free",
|
|
stripe_subscription_id=None,
|
|
status="canceled",
|
|
end_date=datetime.now(timezone.utc).isoformat(),
|
|
current_period_end=None,
|
|
grace_period_end=None,
|
|
)
|
|
_update_user_plan(db, user_id, "free")
|
|
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
|
logger.info("subscription.deleted: user %s downgraded to free", user_id)
|
|
|
|
|
|
def _handle_payment_failed(db, event):
|
|
"""invoice.payment_failed → mark past_due + 3-day grace period."""
|
|
invoice = event["data"]["object"]
|
|
customer_id = _sget(invoice, "customer")
|
|
subscription_id = _sget(invoice, "subscription")
|
|
|
|
user_id = _resolve_user_from_customer(db, customer_id)
|
|
if not user_id:
|
|
logger.error(
|
|
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
|
|
)
|
|
return
|
|
|
|
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
|
|
|
|
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
|
|
_record_billing_event(
|
|
db,
|
|
event["id"],
|
|
event["type"],
|
|
user_id=user_id,
|
|
payload={"subscription_id": subscription_id},
|
|
)
|
|
|
|
# TODO: send notification email via /api/notifications
|
|
logger.warning(
|
|
"invoice.payment_failed: user %s past_due, grace period until %s",
|
|
user_id,
|
|
grace_end,
|
|
)
|
|
|
|
|
|
def _handle_payment_succeeded(db, event):
|
|
"""invoice.payment_succeeded → clear past_due / grace period."""
|
|
invoice = event["data"]["object"]
|
|
customer_id = _sget(invoice, "customer")
|
|
|
|
user_id = _resolve_user_from_customer(db, customer_id)
|
|
if not user_id:
|
|
return
|
|
|
|
# Refresh subscription period end
|
|
current_period_end = None
|
|
lines = _sget(invoice, "lines") or {}
|
|
lines_data = _sget(lines, "data") or []
|
|
if lines_data:
|
|
period = lines_data[0].get("period") or {}
|
|
period_end = (
|
|
period.get("end") if isinstance(period, dict) else _sget(period, "end")
|
|
)
|
|
if period_end:
|
|
current_period_end = datetime.fromtimestamp(
|
|
period_end, tz=timezone.utc
|
|
).isoformat()
|
|
|
|
_upsert_subscription(
|
|
db,
|
|
user_id,
|
|
status="active",
|
|
grace_period_end=None,
|
|
current_period_end=current_period_end,
|
|
)
|
|
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
|
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/billing/consumption
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _parse_month(month: str):
|
|
"""Validate YYYY-MM format, return (year, month) tuple or None."""
|
|
import re
|
|
if not re.match(r"^\d{4}-\d{2}$", month):
|
|
return None
|
|
parts = month.split("-")
|
|
y, m = int(parts[0]), int(parts[1])
|
|
if m < 1 or m > 12:
|
|
return None
|
|
return y, m
|
|
|
|
|
|
@billing_bp.route("/consumption", methods=["GET"])
|
|
@jwt_required_middleware
|
|
def consumption_status():
|
|
"""
|
|
Return current month consumption vs plan limits for the authenticated user.
|
|
---
|
|
tags:
|
|
- Billing
|
|
security:
|
|
- Bearer: []
|
|
parameters:
|
|
- in: query
|
|
name: month
|
|
type: string
|
|
required: false
|
|
description: "Month in YYYY-MM format (default: current month)"
|
|
responses:
|
|
200:
|
|
description: Consumption status with usage, limits, and alerts
|
|
400:
|
|
description: Invalid parameters
|
|
422:
|
|
description: Malformed month format
|
|
"""
|
|
user = request.current_user
|
|
month = request.args.get("month", datetime.now().strftime("%Y-%m"))
|
|
parsed = _parse_month(month)
|
|
if not parsed:
|
|
return jsonify({"error": "Format mois invalide. Utiliser YYYY-MM"}), 422
|
|
|
|
year, mon = parsed
|
|
plan = user.get("plan", "free")
|
|
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
|
|
|
|
db = get_db()
|
|
try:
|
|
# Aggregate consumption for the given month
|
|
month_start = f"{year:04d}-{mon:02d}-01"
|
|
if mon == 12:
|
|
month_end = f"{year + 1:04d}-01-01"
|
|
else:
|
|
month_end = f"{year:04d}-{mon + 1:02d}-01"
|
|
|
|
row = db.execute(
|
|
"""SELECT
|
|
COALESCE(SUM(api_calls), 0) AS total_api_calls
|
|
FROM consumption_log
|
|
WHERE user_id = ? AND date >= ? AND date < ?""",
|
|
(str(user["id"]), month_start, month_end),
|
|
).fetchone()
|
|
|
|
total_api_calls = row["total_api_calls"] if row else 0
|
|
|
|
# Calculate alert levels
|
|
api_limit = limits["monthly_api_calls"]
|
|
api_pct = round((total_api_calls / api_limit * 100), 1) if api_limit > 0 else 0
|
|
|
|
alerts = []
|
|
if api_pct >= 100:
|
|
alerts.append({
|
|
"type": "hard",
|
|
"metric": "api_calls",
|
|
"message": "Limite mensuelle d'appels API atteinte.",
|
|
"current": total_api_calls,
|
|
"limit": api_limit,
|
|
})
|
|
elif api_pct >= 80:
|
|
alerts.append({
|
|
"type": "soft",
|
|
"metric": "api_calls",
|
|
"message": f"Appels API à {api_pct}% de la limite mensuelle.",
|
|
"current": total_api_calls,
|
|
"limit": api_limit,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error("Consumption query error for user %s: %s", user["id"], e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
resp = jsonify({
|
|
"user_id": user["id"],
|
|
"plan": plan,
|
|
"month": month,
|
|
"consumption": {
|
|
"total_api_calls": total_api_calls,
|
|
"limit_api_calls": api_limit,
|
|
"usage_pct": api_pct,
|
|
},
|
|
"alerts": alerts,
|
|
})
|
|
if any(a["type"] == "hard" for a in alerts):
|
|
resp.headers["X-Billing-Alert"] = "hard_limit_reached"
|
|
elif any(a["type"] == "soft" for a in alerts):
|
|
resp.headers["X-Billing-Alert"] = "soft_limit_warning"
|
|
return resp, 200
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# On-import: ensure DB migration ran
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
try:
|
|
migrate_billing_tables()
|
|
except Exception as _e:
|
|
logger.warning("billing_db migration skipped (test env?): %s", _e)
|