Orquestra create_client/order FOSS, bridge OpenPanel com metadata, upsert res.partner Odoo, mail-bundle wizard e webhook order-activated. Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
8.8 KiB
Python
264 lines
8.8 KiB
Python
"""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
|