"""Billing API routes — Spec 023 + Spec 043.""" from __future__ import annotations import os from typing import Any from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel, Field from app import auth, billing_store from app.client_activation import ActivationError, activate_billing_account from app.permissions import can_manage_billing, can_read_billing, should_mask_sensitive router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) FOSS_WEBHOOK_SECRET = os.getenv("FOSS_WEBHOOK_SECRET", os.getenv("WEBHOOK_SECRET", "")) class PatchBillingBody(BaseModel): billing_state: str | None = None recurrence_active: bool | None = None external_customer_id: str | None = None plan_code: str | None = None activated_by: str | None = None class ActivateBillingBody(BaseModel): plan_code: str = Field(..., min_length=3) config: dict[str, Any] | None = None provision_mail: bool = True notes: str | None = None class FossOrderActivatedBody(BaseModel): order_id: int | str | None = None product_slug: str | None = None client_email: str | None = None domain: str | None = None config: dict[str, Any] | None = None def _reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: if not can_read_billing(user.role): raise HTTPException(403, "permissão insuficiente") return user def _manager(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: if not can_manage_billing(user.role): raise HTTPException(403, "permissão insuficiente") return user @router.get("/summary") def billing_summary(user: auth.DeskUser = Depends(_reader)): conn = auth.db() try: data = billing_store.summary(conn) if should_mask_sensitive(user.role): data["recent_validations"] = [ billing_store._row_dict(r, mask=True) for r in conn.execute( "SELECT * FROM billing_accounts ORDER BY updated_at DESC LIMIT 5" ).fetchall() ] return data finally: conn.close() @router.get("/accounts") def list_billing_accounts( billing_state: str = "", domain: str = "", limit: int = Query(100, ge=1, le=500), user: auth.DeskUser = Depends(_reader), ): conn = auth.db() try: mask = should_mask_sensitive(user.role) return billing_store.list_accounts( conn, billing_state=billing_state.strip() or None, domain=domain.strip() or None, limit=limit, mask=mask, ) finally: conn.close() @router.get("/accounts/by-domain/{domain}") def billing_by_domain(domain: str, user: auth.DeskUser = Depends(_reader)): conn = auth.db() try: acc = billing_store.get_by_domain(conn, domain, mask=should_mask_sensitive(user.role)) finally: conn.close() if not acc: raise HTTPException(404, "conta não encontrada") return acc @router.get("/accounts/{account_id}") def get_billing_account(account_id: int, user: auth.DeskUser = Depends(_reader)): conn = auth.db() try: acc = billing_store.get_account(conn, account_id, mask=should_mask_sensitive(user.role)) finally: conn.close() if not acc: raise HTTPException(404, "conta não encontrada") return acc @router.get("/accounts/{account_id}/activation-preview") def billing_activation_preview( account_id: int, plan_code: str = "", user: auth.DeskUser = Depends(_manager), ): """Preview read-only — Spec 043 Fase 1.""" from app.activation_mapper import build_activation_preview conn = auth.db() try: acc = billing_store.get_account(conn, account_id, mask=False) finally: conn.close() if not acc: raise HTTPException(404, "conta não encontrada") preview = build_activation_preview( acc, plan_code=plan_code.strip() or None, ) if should_mask_sensitive(user.role): preview["company_profile"] = billing_store._mask_profile(preview.get("company_profile") or {}) for key in ("email", "company_vat", "phone"): if preview["mapped"]["foss_client"].get(key): if "email" in key: preview["mapped"]["foss_client"][key] = billing_store._mask_email( preview["mapped"]["foss_client"][key] ) elif key == "company_vat": preview["mapped"]["foss_client"][key] = billing_store._mask_tax_id( preview["mapped"]["foss_client"][key] ) if preview["mapped"]["odoo_partner"].get("email"): preview["mapped"]["odoo_partner"]["email"] = billing_store._mask_email( preview["mapped"]["odoo_partner"]["email"] ) if preview["mapped"]["odoo_partner"].get("vat"): preview["mapped"]["odoo_partner"]["vat"] = billing_store._mask_tax_id( preview["mapped"]["odoo_partner"]["vat"] ) return preview @router.post("/accounts/{account_id}/activate") def billing_activate_account( account_id: int, body: ActivateBillingBody, user: auth.DeskUser = Depends(_manager), ): """Dispara FOSS → OpenPanel → Odoo → wizard — Spec 043.""" conn = auth.db() try: billing_store.init_schema(conn) try: result = activate_billing_account( conn, account_id, plan_code=body.plan_code, config=body.config, provision_mail=body.provision_mail, notes=body.notes, activated_by=user.username, ) except ActivationError as exc: msg = str(exc) if "já activa" in msg.lower(): raise HTTPException(409, msg) from exc if "não encontrada" in msg.lower(): raise HTTPException(404, msg) from exc raise HTTPException(400, msg) from exc finally: conn.close() status = 200 if result.get("ok") else 207 from fastapi.responses import JSONResponse return JSONResponse(status_code=status, content=result) @router.post("/webhook/foss/order-activated") def foss_order_activated( body: FossOrderActivatedBody, request: Request, x_webhook_secret: str | None = Header(default=None), ): """Webhook FOSS pós-activação — provisiona mail-bundle (Spec 035).""" if FOSS_WEBHOOK_SECRET and x_webhook_secret != FOSS_WEBHOOK_SECRET: raise HTTPException(401, "webhook secret inválido") domain = (body.domain or "").strip().lower() if not domain: raise HTTPException(400, "domain obrigatório") conn = auth.db() try: billing_store.init_schema(conn) acc = billing_store.get_by_domain(conn, domain, mask=False) if not acc: raise HTTPException(404, "conta não encontrada para domínio") from app.vm123 import wizard_client from app.activation_mapper import map_wizard_mail_bundle, normalize_company_profile, resolve_plan_config profile = normalize_company_profile(acc.get("company_profile"), domain=domain) code, cfg = resolve_plan_config(body.product_slug or acc.get("plan_code"), body.config) mail_payload = map_wizard_mail_bundle( domain=domain, profile=profile, config=cfg, foss_order_id=body.order_id, ) wizard_step = wizard_client.provision_mail_bundle(mail_payload) billing_store.save_provision_log( conn, int(acc["id"]), { "webhook": "order-activated", "order_id": body.order_id, "wizard": wizard_step, }, ) if body.order_id and not acc.get("external_subscription_id"): billing_store.patch_account( conn, int(acc["id"]), external_subscription_id=str(body.order_id), ) return {"ok": True, "domain": domain, "wizard": wizard_step} finally: conn.close() @router.patch("/accounts/{account_id}") def patch_billing_account( account_id: int, body: PatchBillingBody, user: auth.DeskUser = Depends(_manager), ): conn = auth.db() try: fields = body.model_dump(exclude_none=True) if body.recurrence_active and not fields.get("activated_by"): from datetime import datetime, timezone fields["activated_by"] = user.username fields["activated_at"] = datetime.now(timezone.utc).isoformat() acc = billing_store.patch_account(conn, account_id, **fields) finally: conn.close() if not acc: raise HTTPException(404, "conta não encontrada") return acc