feat(desk): Spec 043 Fases 2–6 — activar conta FOSS/OpenPanel/Odoo

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>
This commit is contained in:
Ligbox Spec Hub 2026-07-01 15:19:54 +00:00
parent f344931925
commit c815a32bbc
15 changed files with 983 additions and 47 deletions

View file

@ -1,23 +1,26 @@
0.14.1-spec043-fase1-preview 0.14.2-spec043-complete
Specs: Specs:
043-desk-client-activation-sync — Fase 1: activation-preview API + UI modal 043-desk-client-activation-sync — Fases 16: preview + activate + FOSS/OP/Odoo/wizard
040-desk-design-system-v013 — UserWizard, Access Control Hub 040-desk-design-system-v013 — UserWizard, Access Control Hub
041-desk-operational-feed — Central Operacional 041-desk-operational-feed — Central Operacional
Rollback Spec 043 Fase 1: Rollback Spec 043 completo:
git checkout desk-v0.14.1-spec043-fase1 -- projects/ops-desk/
Rollback antes de Spec 043:
git checkout desk-v0.14.0-pre-spec043-activation -- projects/ops-desk/ git checkout desk-v0.14.0-pre-spec043-activation -- projects/ops-desk/
Rollback Fase 1 only (manter docs):
git checkout desk-v0.14.0-pre-spec043-activation -- \
projects/ops-desk/api/app/activation_mapper.py \
projects/ops-desk/api/app/billing_routes.py \
projects/ops-desk/frontend/assets/billing-ui.js \
projects/ops-desk/frontend/assets/auth.js \
projects/ops-desk/frontend/assets/styles.css
Tags: Tags:
desk-v0.14.0-pre-spec043-activation — antes de qualquer código Spec 043 desk-v0.14.0-pre-spec043-activation — docs only
desk-v0.14.1-spec043-fase1 — Fase 1 preview UI desk-v0.14.1-spec043-fase1 — preview UI
desk-v0.14.2-spec043-complete — activate FOSS+OP+Odoo+wizard
Cache produção: ?v=20260701spec043f1 Cache produção: ?v=20260701spec043f2
Teste E2E Roger:
1. Desk → Conta cliente → Activar conta → Confirmar
2. Verificar FOSS client/order em financeiro.ligbox.com.br
3. Verificar user OpenPanel no bridge :18087
4. Verificar res.partner no Odoo
5. (Opcional) mail-bundle VM112 se endpoint activo

View file

@ -237,7 +237,6 @@ def build_activation_preview(
"openpanel_username": profile.get("openpanel_username"), "openpanel_username": profile.get("openpanel_username"),
}, },
"notes": ( "notes": (
"Preview read-only — Fase 1 Spec 043. " "Preview read-only — confirmar dados antes de Activar conta."
"POST /activate implementado nas Fases 25."
), ),
} }

View file

@ -1,15 +1,21 @@
"""Billing API routes — Spec 023.""" """Billing API routes — Spec 023 + Spec 043."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query import os
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app import auth, billing_store 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 from app.permissions import can_manage_billing, can_read_billing, should_mask_sensitive
router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
FOSS_WEBHOOK_SECRET = os.getenv("FOSS_WEBHOOK_SECRET", os.getenv("WEBHOOK_SECRET", ""))
class PatchBillingBody(BaseModel): class PatchBillingBody(BaseModel):
billing_state: str | None = None billing_state: str | None = None
@ -19,6 +25,21 @@ class PatchBillingBody(BaseModel):
activated_by: 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: def _reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_read_billing(user.role): if not can_read_billing(user.role):
raise HTTPException(403, "permissão insuficiente") raise HTTPException(403, "permissão insuficiente")
@ -136,6 +157,91 @@ def billing_activation_preview(
return preview 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}") @router.patch("/accounts/{account_id}")
def patch_billing_account( def patch_billing_account(
account_id: int, account_id: int,

View file

@ -36,6 +36,8 @@ def init_schema(conn) -> None:
external_subscription_id TEXT, external_subscription_id TEXT,
payment_provider TEXT, payment_provider TEXT,
plan_code TEXT, plan_code TEXT,
odoo_partner_id TEXT,
provision_json TEXT,
activated_at TEXT, activated_at TEXT,
activated_by TEXT, activated_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@ -44,6 +46,16 @@ def init_schema(conn) -> None:
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_domain ON billing_accounts(domain); CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_domain ON billing_accounts(domain);
""" """
) )
_ensure_columns(conn)
def _ensure_columns(conn) -> None:
cols = {row[1] for row in conn.execute("PRAGMA table_info(billing_accounts)")}
if "odoo_partner_id" not in cols:
conn.execute("ALTER TABLE billing_accounts ADD COLUMN odoo_partner_id TEXT")
if "provision_json" not in cols:
conn.execute("ALTER TABLE billing_accounts ADD COLUMN provision_json TEXT")
conn.commit()
def _mask_tax_id(tax_id: str | None) -> str: def _mask_tax_id(tax_id: str | None) -> str:
@ -85,10 +97,12 @@ def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
"recurrence_active": bool(row["recurrence_active"]), "recurrence_active": bool(row["recurrence_active"]),
"external_customer_id": row["external_customer_id"], "external_customer_id": row["external_customer_id"],
"external_subscription_id": row["external_subscription_id"], "external_subscription_id": row["external_subscription_id"],
"odoo_partner_id": row["odoo_partner_id"] if "odoo_partner_id" in row.keys() else None,
"payment_provider": row["payment_provider"], "payment_provider": row["payment_provider"],
"plan_code": row["plan_code"], "plan_code": row["plan_code"],
"activated_at": row["activated_at"], "activated_at": row["activated_at"],
"activated_by": row["activated_by"], "activated_by": row["activated_by"],
"provision_log": _provision_summary(row),
"created_at": row["created_at"], "created_at": row["created_at"],
"updated_at": row["updated_at"], "updated_at": row["updated_at"],
"links": { "links": {
@ -96,9 +110,27 @@ def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
"odoo": ODOO_URL, "odoo": ODOO_URL,
}, },
} }
if out.get("odoo_partner_id"):
out["links"]["odoo_partner"] = f"{ODOO_URL}&partner_id={out['odoo_partner_id']}"
return out return out
def _provision_summary(row) -> dict[str, Any] | None:
raw = row["provision_json"] if "provision_json" in row.keys() else None
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return {
"ok": data.get("ok"),
"activated_at": data.get("activated_at"),
"activated_by": data.get("activated_by"),
"partial_errors": data.get("partial_errors") or [],
}
def _mask_profile(profile: dict) -> dict: def _mask_profile(profile: dict) -> dict:
p = dict(profile) p = dict(profile)
if p.get("tax_id"): if p.get("tax_id"):
@ -226,11 +258,13 @@ def patch_account(conn, account_id: int, **fields) -> dict[str, Any] | None:
"recurrence_active", "recurrence_active",
"external_customer_id", "external_customer_id",
"external_subscription_id", "external_subscription_id",
"odoo_partner_id",
"payment_provider", "payment_provider",
"plan_code", "plan_code",
"activated_at", "activated_at",
"activated_by", "activated_by",
"ticket_id", "ticket_id",
"provision_json",
} }
if fields.get("recurrence_active"): if fields.get("recurrence_active"):
fields.setdefault("billing_state", "billing_active") fields.setdefault("billing_state", "billing_active")
@ -253,6 +287,36 @@ def patch_account(conn, account_id: int, **fields) -> dict[str, Any] | None:
return get_account(conn, account_id) return get_account(conn, account_id)
def merge_company_profile(conn, account_id: int, updates: dict[str, Any]) -> dict[str, Any] | None:
row = conn.execute(
"SELECT company_profile_json FROM billing_accounts WHERE id = ?",
(account_id,),
).fetchone()
if not row:
return None
profile: dict[str, Any] = {}
if row["company_profile_json"]:
try:
profile = json.loads(row["company_profile_json"])
except json.JSONDecodeError:
profile = {}
profile.update(updates)
conn.execute(
"UPDATE billing_accounts SET company_profile_json = ?, updated_at = ? WHERE id = ?",
(json.dumps(profile, ensure_ascii=False), _now(), account_id),
)
conn.commit()
return get_account(conn, account_id)
def save_provision_log(conn, account_id: int, log: dict[str, Any]) -> None:
conn.execute(
"UPDATE billing_accounts SET provision_json = ?, updated_at = ? WHERE id = ?",
(json.dumps(log, ensure_ascii=False), _now(), account_id),
)
conn.commit()
def summary(conn) -> dict[str, Any]: def summary(conn) -> dict[str, Any]:
pending = conn.execute( pending = conn.execute(
"SELECT COUNT(*) FROM billing_accounts WHERE billing_state = 'awaiting_billing_validation'" "SELECT COUNT(*) FROM billing_accounts WHERE billing_state = 'awaiting_billing_validation'"

View file

@ -0,0 +1,262 @@
"""Orquestração activação cliente — Spec 043 Fases 25."""
from __future__ import annotations
import json
import logging
import secrets
from datetime import datetime, timezone
from typing import Any
from app import billing_store
from app.activation_mapper import (
ACTIVATABLE_STATES,
build_activation_preview,
map_foss_client,
map_odoo_partner,
map_openpanel,
map_wizard_mail_bundle,
normalize_company_profile,
resolve_plan_config,
)
from app.vm123 import foss_client, odoo_client, openpanel_client, wizard_client
log = logging.getLogger(__name__)
class ActivationError(RuntimeError):
pass
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def activate_billing_account(
conn,
account_id: int,
*,
plan_code: str | None = None,
config: dict[str, Any] | None = None,
provision_mail: bool = True,
notes: str | None = None,
activated_by: str,
skip_openpanel: bool = False,
) -> dict[str, Any]:
"""Sequência FOSS → OpenPanel → Odoo → wizard mail-bundle."""
acc = billing_store.get_account(conn, account_id, mask=False)
if not acc:
raise ActivationError("conta não encontrada")
if acc.get("recurrence_active") or acc.get("billing_state") == "billing_active":
raise ActivationError("conta já activa")
if acc.get("billing_state") not in ACTIVATABLE_STATES:
raise ActivationError(f"estado {acc.get('billing_state')} não permite activação")
preview = build_activation_preview(acc, plan_code=plan_code, config=config)
domain = preview["domain"]
profile = preview["company_profile"]
code = preview["plan_code"]
cfg = preview["config"]
steps: dict[str, Any] = {}
partial_errors: list[str] = []
billing_store.patch_account(
conn,
account_id,
billing_state="provisioning",
plan_code=code,
activated_by=activated_by,
activated_at=_now(),
)
foss_client_id = acc.get("external_customer_id")
foss_order_id = acc.get("external_subscription_id")
# —— Fase 2: FOSS client + order ——
try:
if foss_client_id:
steps["foss"] = {
"client_id": foss_client_id,
"created": False,
"reused": True,
}
else:
mapped_client = map_foss_client(profile, domain)
foss_step = foss_client.create_client(mapped=mapped_client)
foss_client_id = foss_step.get("client_id")
steps["foss"] = {
"client_id": foss_client_id,
"created": bool(foss_step.get("created")),
"reused": bool(foss_step.get("reused")),
}
if foss_client_id:
billing_store.patch_account(
conn,
account_id,
external_customer_id=str(foss_client_id),
)
except Exception as exc:
log.warning("FOSS client failed domain=%s: %s", domain, exc)
steps["foss"] = {"error": str(exc)}
partial_errors.append(f"FOSS client: {exc}")
if foss_client_id and not foss_order_id:
try:
order_step = foss_client.create_order(
client_id=foss_client_id,
product_slug=code,
domain=domain,
config=cfg,
manager_email=profile.get("manager_email"),
manager_name=profile.get("manager_name"),
activate=True,
)
foss_order_id = order_step.get("order_id")
steps["foss"]["order_id"] = foss_order_id
steps["foss"]["order_created"] = True
if foss_order_id:
billing_store.patch_account(
conn,
account_id,
external_subscription_id=str(foss_order_id),
)
except Exception as exc:
log.warning("FOSS order failed domain=%s: %s", domain, exc)
steps.setdefault("foss", {})["order_error"] = str(exc)
partial_errors.append(f"FOSS order: {exc}")
elif foss_order_id:
steps.setdefault("foss", {})["order_id"] = foss_order_id
steps["foss"]["order_created"] = False
# —— Fase 3: OpenPanel (fallback directo se módulo FOSS não provisionou) ——
op_mapped = map_openpanel(domain=domain, profile=profile, plan_code=code, config=cfg)
username = op_mapped["username"]
if skip_openpanel or not openpanel_client.bridge_configured():
steps["openpanel"] = {
"skipped": True,
"reason": "bridge não configurado" if not openpanel_client.bridge_configured() else "skip_openpanel",
"username": username,
}
elif profile.get("openpanel_username"):
steps["openpanel"] = {
"username": profile.get("openpanel_username"),
"domain": domain,
"created": False,
"reused": True,
}
else:
try:
panel_pwd = secrets.token_urlsafe(12)
op_step = openpanel_client.provision_user_safe(
username=username,
password=panel_pwd,
email=str(profile.get("manager_email") or ""),
domain=domain,
plan_name=code,
metadata=op_mapped.get("metadata"),
)
steps["openpanel"] = {
"username": username,
"domain": domain,
"success": True,
"created": bool(op_step.get("created", True)),
"reused": bool(op_step.get("reused")),
}
billing_store.merge_company_profile(
conn,
account_id,
{
"openpanel_username": username,
"openpanel_domain": domain,
},
)
except Exception as exc:
log.warning("OpenPanel failed domain=%s: %s", domain, exc)
steps["openpanel"] = {"username": username, "error": str(exc)}
partial_errors.append(f"OpenPanel: {exc}")
# —— Fase 4: Odoo partner ——
odoo_partner_id = acc.get("odoo_partner_id")
try:
partner_mapped = map_odoo_partner(profile, domain, foss_client_id=foss_client_id)
if odoo_partner_id:
steps["odoo"] = {
"partner_id": int(odoo_partner_id),
"created": False,
"reused": True,
}
else:
odoo_step = odoo_client.upsert_customer_partner(mapped=partner_mapped)
odoo_partner_id = odoo_step.get("partner_id")
steps["odoo"] = {
"partner_id": odoo_partner_id,
"created": bool(odoo_step.get("created")),
"updated": bool(odoo_step.get("updated")),
}
if odoo_partner_id:
billing_store.patch_account(
conn,
account_id,
odoo_partner_id=str(odoo_partner_id),
)
except Exception as exc:
log.warning("Odoo partner failed domain=%s: %s", domain, exc)
steps["odoo"] = {"error": str(exc)}
partial_errors.append(f"Odoo: {exc}")
# —— Fase 5: Wizard mail-bundle ——
if provision_mail and code.startswith("ligbox-mail"):
try:
mail_payload = map_wizard_mail_bundle(
domain=domain,
profile=profile,
config=cfg,
foss_order_id=foss_order_id,
)
wizard_step = wizard_client.provision_mail_bundle(mail_payload)
steps["wizard"] = wizard_step
if wizard_step.get("skipped"):
steps["wizard"]["provisioned"] = False
else:
steps["wizard"]["provisioned"] = bool(wizard_step.get("provisioned"))
except Exception as exc:
log.warning("Wizard mail-bundle failed domain=%s: %s", domain, exc)
steps["wizard"] = {"error": str(exc), "provisioned": False}
partial_errors.append(f"Wizard: {exc}")
else:
steps["wizard"] = {"skipped": True, "reason": "provision_mail=false ou plano não-mail"}
ok = not partial_errors
final_state = "billing_active" if ok else "provisioning"
billing_store.patch_account(
conn,
account_id,
billing_state=final_state,
recurrence_active=ok,
plan_code=code,
)
billing_store.save_provision_log(
conn,
account_id,
{
"activated_at": _now(),
"activated_by": activated_by,
"notes": notes,
"steps": steps,
"partial_errors": partial_errors,
"ok": ok,
},
)
out = {
"ok": ok,
"billing_account_id": account_id,
"domain": domain,
"billing_state": final_state,
"steps": steps,
"activated_at": _now(),
"activated_by": activated_by,
}
if partial_errors:
out["message"] = "".join(partial_errors)
return out

View file

@ -42,9 +42,29 @@ def _post(path: str, payload: dict) -> dict[str, Any]:
return {"raw": res.text} return {"raw": res.text}
def _extract_id(data: dict[str, Any]) -> int | str | None:
if data.get("id") is not None:
return data["id"]
result = data.get("result")
if isinstance(result, dict) and result.get("id") is not None:
return result["id"]
if result is not None and not isinstance(result, dict):
return result
return None
def _list_items(data: dict[str, Any]) -> list[dict[str, Any]]:
result = data.get("result")
if isinstance(result, dict) and isinstance(result.get("list"), list):
return result["list"]
if isinstance(data.get("list"), list):
return data["list"]
return []
def find_client_by_email(email: str) -> dict[str, Any] | None: def find_client_by_email(email: str) -> dict[str, Any] | None:
data = _post("client/get_list", {"per_page": 50, "search": email.strip()}) data = _post("client/get_list", {"per_page": 50, "search": email.strip()})
items = data.get("result", {}).get("list") if isinstance(data.get("result"), dict) else data.get("list") items = _list_items(data)
if not items: if not items:
return None return None
needle = email.strip().lower() needle = email.strip().lower()
@ -57,7 +77,7 @@ def find_client_by_email(email: str) -> dict[str, Any] | None:
def find_client_by_domain(domain: str) -> dict[str, Any] | None: def find_client_by_domain(domain: str) -> dict[str, Any] | None:
dom = domain.strip().lower() dom = domain.strip().lower()
data = _post("client/get_list", {"per_page": 100}) data = _post("client/get_list", {"per_page": 100})
items = data.get("result", {}).get("list") if isinstance(data.get("result"), dict) else data.get("list") or [] items = _list_items(data)
for item in items: for item in items:
for field in ("company", "company_vat", "email"): for field in ("company", "company_vat", "email"):
val = str(item.get(field, "")).lower() val = str(item.get(field, "")).lower()
@ -66,6 +86,121 @@ def find_client_by_domain(domain: str) -> dict[str, Any] | None:
return None return None
def find_product_by_slug(slug: str) -> dict[str, Any] | None:
needle = slug.strip().lower()
data = _post("product/get_list", {"per_page": 100})
for item in _list_items(data):
if str(item.get("slug", "")).lower() == needle:
return item
title_slug = str(item.get("title", "")).lower().replace(" ", "-")
if title_slug == needle:
return item
return None
def create_client(*, mapped: dict[str, Any], password: str | None = None) -> dict[str, Any]:
"""Cria cliente FOSS — idempotente por email (Spec 043 §4.1)."""
email = str(mapped.get("email") or "").strip().lower()
if not email:
raise ValueError("email obrigatório para FOSS client")
existing = find_client_by_email(email)
if existing:
cid = existing.get("id")
return {
"client_id": cid,
"email": email,
"created": False,
"reused": True,
}
pwd = password or secrets.token_urlsafe(14)
payload: dict[str, Any] = {
"email": email,
"first_name": mapped.get("first_name") or "",
"last_name": mapped.get("last_name") or "",
"company": mapped.get("company") or "",
"company_vat": mapped.get("company_vat") or "",
"phone": mapped.get("phone") or "",
"address_1": mapped.get("address_1") or "",
"city": mapped.get("city") or "",
"state": mapped.get("state") or "",
"postcode": mapped.get("postcode") or "",
"country": mapped.get("country") or "BR",
"currency": mapped.get("currency") or "BRL",
"status": mapped.get("status") or "active",
"password": pwd,
"password_confirm": pwd,
}
result = _post("client/create", payload)
cid = _extract_id(result)
return {
"client_id": cid,
"email": email,
"created": True,
"reused": False,
"admin_url": FOSS_PUBLIC_ADMIN,
}
def _split_domain(domain: str) -> tuple[str, str]:
parts = domain.strip().lower().split(".")
if len(parts) < 2:
return (parts[0], "com.br")
return (parts[0], ".".join(parts[1:]))
def create_order(
*,
client_id: int | str,
product_slug: str,
domain: str,
period: str = "1M",
activate: bool = True,
config: dict[str, Any] | None = None,
manager_email: str | None = None,
manager_name: str | None = None,
) -> dict[str, Any]:
"""Cria e activa pedido hosting FOSS (Spec 043 §4.2)."""
product = find_product_by_slug(product_slug)
if not product:
raise RuntimeError(f"produto FOSS não encontrado: {product_slug}")
product_id = product.get("id")
sld, tld = _split_domain(domain)
payload: dict[str, Any] = {
"client_id": client_id,
"product_id": product_id,
"period": period,
"currency": "BRL",
"activate": 1 if activate else 0,
"config": {
"domain": {
"action": "owndomain",
"owndomain_sld": sld,
"owndomain_tld": tld,
},
},
}
if config:
for key, val in config.items():
if val is not None:
payload["config"][key] = val
if manager_email:
payload["config"]["manager_email"] = manager_email
if manager_name:
payload["config"]["manager_name"] = manager_name
payload["config"]["domain_name"] = domain
result = _post("order/create", payload)
order_id = _extract_id(result)
return {
"order_id": order_id,
"client_id": client_id,
"product_id": product_id,
"product_slug": product_slug,
"domain": domain,
"activated": activate,
"created": True,
}
def staff_group_name_for_role(desk_role: str) -> str | None: def staff_group_name_for_role(desk_role: str) -> str | None:
return FOSS_GROUP_BY_ROLE.get(desk_role) return FOSS_GROUP_BY_ROLE.get(desk_role)

View file

@ -165,11 +165,99 @@ def find_partner_by_email(email: str) -> dict[str, Any] | None:
"res.partner", "res.partner",
"search_read", "search_read",
[[("email", "=ilike", email.strip())]], [[("email", "=ilike", email.strip())]],
{"fields": ["id", "name", "email", "vat"], "limit": 1}, {"fields": ["id", "name", "email", "vat", "ref"], "limit": 1},
) )
return rows[0] if rows else None return rows[0] if rows else None
def find_partner_by_ref(ref: str) -> dict[str, Any] | None:
uid, models = _client()
rows = models.execute_kw(
ODOO_DB,
uid,
ODOO_API_KEY,
"res.partner",
"search_read",
[[("ref", "=", ref.strip().lower())]],
{"fields": ["id", "name", "email", "vat", "ref"], "limit": 1},
)
return rows[0] if rows else None
def _country_id(uid: int, models, country_code: str) -> int | None:
code = (country_code or "BR").strip().upper()
rows = models.execute_kw(
ODOO_DB,
uid,
ODOO_API_KEY,
"res.country",
"search_read",
[[("code", "=", code)]],
{"fields": ["id"], "limit": 1},
)
return int(rows[0]["id"]) if rows else None
def upsert_customer_partner(*, mapped: dict[str, Any]) -> dict[str, Any]:
"""Empresa cliente res.partner — Spec 043 §6.1."""
import json
uid, models = _client()
ref = str(mapped.get("ref") or "").strip().lower()
email = str(mapped.get("email") or "").strip()
existing = find_partner_by_ref(ref) if ref else None
if not existing and email:
existing = find_partner_by_email(email)
comment = mapped.get("comment")
if isinstance(comment, dict):
comment = json.dumps(comment, ensure_ascii=False)
vals: dict[str, Any] = {
"name": mapped.get("name") or ref or email,
"vat": mapped.get("vat") or False,
"email": email or False,
"phone": mapped.get("phone") or False,
"street": mapped.get("street") or False,
"city": mapped.get("city") or False,
"zip": mapped.get("zip") or False,
"company_type": mapped.get("company_type") or "company",
"customer_rank": mapped.get("customer_rank") or 1,
"ref": ref or False,
"comment": comment or False,
}
country_id = _country_id(uid, models, str(mapped.get("country_code") or "BR"))
if country_id:
vals["country_id"] = country_id
if existing:
models.execute_kw(
ODOO_DB,
uid,
ODOO_API_KEY,
"res.partner",
"write",
[[int(existing["id"])], vals],
)
return {
"partner_id": int(existing["id"]),
"created": False,
"updated": True,
"login_url": ODOO_PUBLIC_URL,
}
partner_id = models.execute_kw(
ODOO_DB,
uid,
ODOO_API_KEY,
"res.partner",
"create",
[vals],
)
return {
"partner_id": int(partner_id),
"created": True,
"updated": False,
"login_url": ODOO_PUBLIC_URL,
}
def find_user_by_login(login: str) -> dict[str, Any] | None: def find_user_by_login(login: str) -> dict[str, Any] | None:
uid, models = _client() uid, models = _client()
rows = models.execute_kw( rows = models.execute_kw(

View file

@ -83,14 +83,17 @@ def provision_user(
email: str, email: str,
domain: str, domain: str,
plan_name: str | None = None, plan_name: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = { payload: dict[str, Any] = {
"username": username.strip().lower(), "username": username.strip().lower(),
"password": password, "password": password,
"email": email, "email": email,
"domain": domain.strip().lower(), "domain": domain.strip().lower(),
"plan_name": plan_name or DEFAULT_PLAN, "plan_name": plan_name or DEFAULT_PLAN,
} }
if metadata:
payload["metadata"] = metadata
with httpx.Client(timeout=180.0) as client: with httpx.Client(timeout=180.0) as client:
res = client.post(f"{BRIDGE_URL}/api/users", headers=_headers(), json=payload) res = client.post(f"{BRIDGE_URL}/api/users", headers=_headers(), json=payload)
data = res.json() if res.content else {} data = res.json() if res.content else {}
@ -99,6 +102,45 @@ def provision_user(
return data return data
def provision_user_safe(
*,
username: str,
password: str,
email: str,
domain: str,
plan_name: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Idempotente — reutiliza user existente se bridge reportar duplicado."""
try:
out = provision_user(
username=username,
password=password,
email=email,
domain=domain,
plan_name=plan_name,
metadata=metadata,
)
out["created"] = True
return out
except OpenPanelBridgeError as exc:
msg = str(exc).lower()
if "exist" in msg or "already" in msg:
try:
existing = get_user(username)
return {
"success": True,
"created": False,
"reused": True,
"username": username,
"domain": domain,
"response": existing,
}
except OpenPanelBridgeError:
raise exc
raise
def add_domain(*, username: str, domain: str) -> dict[str, Any]: def add_domain(*, username: str, domain: str) -> dict[str, Any]:
payload = {"username": username.strip().lower(), "domain": domain.strip().lower()} payload = {"username": username.strip().lower(), "domain": domain.strip().lower()}
with httpx.Client(timeout=180.0) as client: with httpx.Client(timeout=180.0) as client:

View file

@ -0,0 +1,48 @@
"""Cliente interno Wizard VM112 — mail-bundle (Spec 035 / 043)."""
from __future__ import annotations
import os
from typing import Any
import httpx
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090").rstrip("/")
OPS_INTERNAL_TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "")
def configured() -> bool:
return bool(OPS_INTERNAL_TOKEN)
def provision_mail_bundle(payload: dict[str, Any]) -> dict[str, Any]:
if not configured():
return {
"skipped": True,
"reason": "OPS_INTERNAL_TOKEN ausente no Desk",
}
url = f"{VM112_API}/api/internal/provision/mail-bundle"
headers = {
"X-Ops-Internal-Token": OPS_INTERNAL_TOKEN,
"Content-Type": "application/json",
}
with httpx.Client(timeout=120.0) as client:
res = client.post(url, json=payload, headers=headers)
if res.status_code == 404:
return {
"skipped": True,
"reason": "endpoint mail-bundle ainda não disponível na VM112",
"status": 404,
}
if res.status_code >= 400:
raise RuntimeError(f"wizard mail-bundle HTTP {res.status_code}: {res.text[:300]}")
try:
body = res.json()
except Exception:
body = {"raw": res.text}
return {
"provisioned": True,
"status": res.status_code,
"response": body,
"mail_host": body.get("mail_host") or f"mail.{payload.get('domain', '')}",
}

View file

@ -0,0 +1,150 @@
"""Unit tests — Spec 043 client activation orchestration."""
from __future__ import annotations
import importlib.util
import sqlite3
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
API_ROOT = Path(__file__).resolve().parents[1]
def _load(name: str, rel_path: str):
path = API_ROOT / rel_path
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ImportError(path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
billing_store = _load("billing_store_043", "app/billing_store.py")
activation_mapper = _load("activation_mapper_043b", "app/activation_mapper.py")
# Stubs vm123 para evitar import FastAPI em ambiente de teste mínimo
_mock_foss = types.ModuleType("app.vm123.foss_client")
_mock_odoo = types.ModuleType("app.vm123.odoo_client")
_mock_op = types.ModuleType("app.vm123.openpanel_client")
_mock_wizard = types.ModuleType("app.vm123.wizard_client")
_mock_vm123 = types.ModuleType("app.vm123")
_mock_vm123.foss_client = _mock_foss
_mock_vm123.odoo_client = _mock_odoo
_mock_vm123.openpanel_client = _mock_op
_mock_vm123.wizard_client = _mock_wizard
for key, mod in {
"app": types.ModuleType("app"),
"app.vm123": _mock_vm123,
"app.vm123.foss_client": _mock_foss,
"app.vm123.odoo_client": _mock_odoo,
"app.vm123.openpanel_client": _mock_op,
"app.vm123.wizard_client": _mock_wizard,
"app.billing_store": billing_store,
"app.activation_mapper": activation_mapper,
}.items():
sys.modules.setdefault(key, mod)
sys.modules["app"].billing_store = billing_store
client_activation = _load("client_activation_043", "app/client_activation.py")
def _memory_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
billing_store.init_schema(conn)
return conn
def _seed_account(conn, domain: str = "acme.com.br") -> int:
billing_store.upsert_from_company_validated(
conn,
domain=domain,
session_id="sess-1",
ticket_id=99,
data={
"billing_state": "awaiting_billing_validation",
"company_profile": {
"legal_name": "ACME LTDA",
"trade_name": "ACME",
"tax_id": "00000000000191",
"email_billing": "fin@acme.com",
"manager_name": "João Silva",
"manager_email": "admin@acme.com.br",
"address": {"country": "BR", "city": "São Paulo", "postal_code": "01310-100"},
},
},
)
row = conn.execute("SELECT id FROM billing_accounts WHERE domain = ?", (domain,)).fetchone()
return int(row["id"])
class TestClientActivation043(unittest.TestCase):
def test_activate_full_happy_path(self):
_mock_foss.create_client = MagicMock(return_value={"client_id": 101, "created": True, "reused": False})
_mock_foss.create_order = MagicMock(return_value={"order_id": 55, "created": True})
_mock_op.bridge_configured = MagicMock(return_value=True)
_mock_op.provision_user_safe = MagicMock(return_value={"success": True, "created": True})
_mock_odoo.upsert_customer_partner = MagicMock(return_value={"partner_id": 88, "created": True})
_mock_wizard.provision_mail_bundle = MagicMock(
return_value={"provisioned": True, "mail_host": "mail.acme.com.br"}
)
conn = _memory_conn()
acc_id = _seed_account(conn)
result = client_activation.activate_billing_account(
conn,
acc_id,
plan_code="ligbox-mail-business",
activated_by="roger",
)
self.assertTrue(result["ok"])
self.assertEqual(result["steps"]["foss"]["client_id"], 101)
self.assertEqual(result["steps"]["foss"]["order_id"], 55)
self.assertEqual(result["steps"]["odoo"]["partner_id"], 88)
acc = billing_store.get_account(conn, acc_id)
self.assertEqual(acc["billing_state"], "billing_active")
self.assertTrue(acc["recurrence_active"])
self.assertEqual(acc["external_customer_id"], "101")
def test_activate_idempotent_foss_client(self):
_mock_foss.create_client = MagicMock()
_mock_foss.create_order = MagicMock(return_value={"order_id": 12})
_mock_op.bridge_configured = MagicMock(return_value=False)
_mock_odoo.upsert_customer_partner = MagicMock(return_value={"partner_id": 3, "created": False})
_mock_wizard.provision_mail_bundle = MagicMock(return_value={"skipped": True})
conn = _memory_conn()
acc_id = _seed_account(conn)
billing_store.patch_account(conn, acc_id, external_customer_id="77")
result = client_activation.activate_billing_account(
conn, acc_id, plan_code="ligbox-mail-business", activated_by="roger"
)
_mock_foss.create_client.assert_not_called()
self.assertTrue(result["steps"]["foss"]["reused"])
def test_activate_rejects_active_account(self):
conn = _memory_conn()
acc_id = _seed_account(conn)
billing_store.patch_account(conn, acc_id, recurrence_active=True, billing_state="billing_active")
with self.assertRaises(client_activation.ActivationError):
client_activation.activate_billing_account(
conn, acc_id, plan_code="ligbox-mail-business", activated_by="roger"
)
class TestBillingStore043(unittest.TestCase):
def test_merge_company_profile(self):
conn = _memory_conn()
acc_id = _seed_account(conn)
billing_store.merge_company_profile(conn, acc_id, {"openpanel_username": "acmex"})
acc = billing_store.get_account(conn, acc_id)
self.assertEqual(acc["company_profile"]["openpanel_username"], "acmex")
if __name__ == "__main__":
unittest.main()

View file

@ -43,9 +43,8 @@ const DeskBilling = (() => {
</section>`; </section>`;
} }
async function openActivationPreview(accountId, domain) { async function openActivationPreview(accountId, domain, planCode) {
const planSel = document.querySelector('[data-billing-plan]'); const plan = planCode || document.querySelector('[data-billing-plan]')?.value || '';
const plan = planSel?.value || '';
const qs = plan ? `?plan_code=${encodeURIComponent(plan)}` : ''; const qs = plan ? `?plan_code=${encodeURIComponent(plan)}` : '';
const preview = await api(`/v1/billing/accounts/${accountId}/activation-preview${qs}`); const preview = await api(`/v1/billing/accounts/${accountId}/activation-preview${qs}`);
const m = preview.mapped || {}; const m = preview.mapped || {};
@ -53,6 +52,7 @@ const DeskBilling = (() => {
const op = m.openpanel || {}; const op = m.openpanel || {};
const odoo = m.odoo_partner || {}; const odoo = m.odoo_partner || {};
const meta = op.metadata || {}; const meta = op.metadata || {};
const canGo = preview.can_activate !== false;
const backdrop = document.createElement('div'); const backdrop = document.createElement('div');
backdrop.className = 'billing-modal-backdrop'; backdrop.className = 'billing-modal-backdrop';
@ -67,13 +67,48 @@ const DeskBilling = (() => {
</div> </div>
<p class="ticket-meta" style="margin-top:0.75rem">${esc(preview.notes || '')}</p> <p class="ticket-meta" style="margin-top:0.75rem">${esc(preview.notes || '')}</p>
<div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap"> <div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap">
<button type="button" class="btn btn-primary btn-sm" data-billing-activate-confirm disabled title="Fase 25 Spec 043">Activar conta (em breve)</button> ${canGo ? `<button type="button" class="btn btn-primary btn-sm" data-billing-activate-confirm>Confirmar activação</button>` : ''}
<button type="button" class="btn btn-sm" data-billing-close>Fechar</button> <button type="button" class="btn btn-sm" data-billing-close>Fechar</button>
</div> </div>
<p class="ticket-meta" data-billing-activate-status style="min-height:1.2em;margin-top:0.5rem"></p>
</div>`; </div>`;
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); }); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); });
backdrop.querySelector('[data-billing-close]')?.addEventListener('click', closeModal); backdrop.querySelector('[data-billing-close]')?.addEventListener('click', closeModal);
backdrop.querySelector('[data-billing-activate-confirm]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const statusEl = backdrop.querySelector('[data-billing-activate-status]');
btn.disabled = true;
btn.textContent = 'A activar…';
try {
const res = await fetch(`${API}/v1/billing/accounts/${accountId}/activate`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
plan_code: preview.plan_code,
config: preview.config,
provision_mail: true,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok && res.status !== 207) {
throw new Error(data.detail || data.message || res.statusText);
}
const fossOk = data.steps?.foss?.client_id ? '✅' : '⚠️';
const opOk = data.steps?.openpanel?.success || data.steps?.openpanel?.reused ? '✅' : '⚠️';
const odooOk = data.steps?.odoo?.partner_id ? '✅' : '⚠️';
statusEl.innerHTML = `${data.ok ? '✅ Conta activada' : '⚠️ Parcial'} — FOSS ${fossOk} · OP ${opOk} · Odoo ${odooOk}`;
if (data.message) statusEl.innerHTML += `<br><span class="ticket-meta">${esc(data.message)}</span>`;
btn.textContent = data.ok ? 'Concluído' : 'Concluído com avisos';
if (typeof state !== 'undefined' && state.view === 'overview-home' && typeof renderOverviewHome === 'function') {
await renderOverviewHome();
}
} catch (err) {
statusEl.textContent = err.message || 'Erro na activação';
btn.disabled = false;
btn.textContent = 'Tentar novamente';
}
});
} }
async function openAccountModal(domain) { async function openAccountModal(domain) {
@ -101,6 +136,7 @@ const DeskBilling = (() => {
<p class="ticket-meta"> <p class="ticket-meta">
<a href="${esc(acc.links?.fossbilling || '#')}" target="_blank" rel="noreferrer">FOSSBilling 💳</a> <a href="${esc(acc.links?.fossbilling || '#')}" target="_blank" rel="noreferrer">FOSSBilling 💳</a>
· <a href="${esc(acc.links?.odoo || '#')}" target="_blank" rel="noreferrer">Odoo</a> · <a href="${esc(acc.links?.odoo || '#')}" target="_blank" rel="noreferrer">Odoo</a>
${acc.links?.odoo_partner ? ` · <a href="${esc(acc.links.odoo_partner)}" target="_blank" rel="noreferrer">Parceiro Odoo</a>` : ''}
</p> </p>
<div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap"> <div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap">
${canActivate() && !acc.recurrence_active ? `<button type="button" class="btn btn-primary btn-sm" data-billing-activate="${acc.id}" data-domain="${esc(domain)}">Activar conta</button>` : ''} ${canActivate() && !acc.recurrence_active ? `<button type="button" class="btn btn-primary btn-sm" data-billing-activate="${acc.id}" data-domain="${esc(domain)}">Activar conta</button>` : ''}
@ -115,8 +151,9 @@ const DeskBilling = (() => {
const btn = e.currentTarget; const btn = e.currentTarget;
const id = btn.getAttribute('data-billing-activate'); const id = btn.getAttribute('data-billing-activate');
const dom = btn.getAttribute('data-domain') || domain; const dom = btn.getAttribute('data-domain') || domain;
const plan = backdrop.querySelector('[data-billing-plan]')?.value || '';
try { try {
await openActivationPreview(id, dom); await openActivationPreview(id, dom, plan);
} catch (err) { } catch (err) {
alert(err.message || 'Erro ao carregar preview'); alert(err.message || 'Erro ao carregar preview');
} }

View file

@ -7,7 +7,7 @@
| Tag | Conteúdo | | Tag | Conteúdo |
|-----|----------| |-----|----------|
| `desk-v0.14.0-pre-spec043-activation` | Baseline **antes** de implementar Fases 16 | | `desk-v0.14.0-pre-spec043-activation` | Baseline **antes** de implementar Fases 16 |
| `desk-v0.14.1-spec043-fase1` | Após Fase 1 (preview UI + API) | | `desk-v0.14.2-spec043-complete` | Fases 26 — activate FOSS+OP+Odoo+wizard |
## Reverter código Desk (VM122) ## Reverter código Desk (VM122)

View file

@ -2,7 +2,7 @@
**Spec:** [043](../spec.md) · depende de [activation-field-mapping.md](./activation-field-mapping.md) **Spec:** [043](../spec.md) · depende de [activation-field-mapping.md](./activation-field-mapping.md)
**Status:** 📋 Planeada — não implementada **Status:** ✅ Implementada — Spec 043
**Base URL:** `https://desk.ligbox.com.br/api/v1` **Base URL:** `https://desk.ligbox.com.br/api/v1`
--- ---

View file

@ -2,7 +2,7 @@
**Criado:** 2026-07-01 **Criado:** 2026-07-01
**Solicitado por:** Roger **Solicitado por:** Roger
**Status:** 🚧 **Fase 1 em curso** — preview UI + API; Fases 26 pendentes **Status:** **Implementação completa (Fases 16 código)** — aguarda teste E2E Roger na VM122
**Prioridade:** P0 (orquestração comercial Ligbox) **Prioridade:** P0 (orquestração comercial Ligbox)
**Sistemas:** VM112 Wizard · VM122 Desk · VM123 FOSS/Odoo/OpenPanel **Sistemas:** VM112 Wizard · VM122 Desk · VM123 FOSS/Odoo/OpenPanel

View file

@ -6,34 +6,36 @@
- [x] Contrato API `desk-activate-account-api.md` - [x] Contrato API `desk-activate-account-api.md`
- [x] Ligações cruzadas specs 023/024/028/035 - [x] Ligações cruzadas specs 023/024/028/035
- [x] Registo em `SPEC-REGISTRY.md` - [x] Registo em `SPEC-REGISTRY.md`
- [x] `ROLLBACK.md` com tags Git
## Fase 1 — Desk preview UI ## Fase 1 — Desk preview UI
- [x] `GET /billing/accounts/{id}/activation-preview` - [x] `GET /billing/accounts/{id}/activation-preview`
- [x] Botão **Activar conta** em `billing-ui.js` (só roles autorizados) - [x] Botão **Activar conta** em `billing-ui.js` (só roles autorizados)
- [x] Modal confirmação com 3 colunas FOSS / OpenPanel / Odoo - [x] Modal confirmação com 3 colunas FOSS / OpenPanel / Odoo
- [ ] Deploy VM122 + validação Roger - [ ] Deploy VM122 + validação Roger (teste manual)
## Fase 2 — FOSS client + order ## Fase 2 — FOSS client + order
- [ ] `foss_client.create_client()` conforme mapa §4 - [x] `foss_client.create_client()` conforme mapa §4
- [ ] `foss_client.create_order()` + activate hosting - [x] `foss_client.create_order()` + activate hosting
- [ ] Custom fields FOSS: `ligbox_domain`, `manager_name`, `manager_email` - [x] Custom fields FOSS via `config` (domain, manager_*)
- [ ] Persistir `external_customer_id` - [x] Persistir `external_customer_id` + `external_subscription_id`
## Fase 3 — OpenPanel ## Fase 3 — OpenPanel
- [ ] Confirmar fluxo via módulo FOSS vs bridge directo - [x] Bridge directo (`provision_user_safe`) + metadata JSON
- [ ] Metadata JSON mail no user hub - [x] Persistir `openpanel_username` em `company_profile_json`
- [ ] Persistir `openpanel_username` em `company_profile_json` - [x] Idempotência user exists
## Fase 4 — Odoo empresa ## Fase 4 — Odoo empresa
- [ ] `odoo_client.upsert_customer_partner()` conforme mapa §6 - [x] `odoo_client.upsert_customer_partner()` conforme mapa §6
- [ ] Coluna `odoo_partner_id` em `billing_accounts` - [x] Coluna `odoo_partner_id` em `billing_accounts`
- [ ] Deep-link Odoo na ficha cliente - [x] Deep-link Odoo na ficha cliente
## Fase 5 — Wizard mail-bundle ## Fase 5 — Wizard mail-bundle
- [ ] `POST /billing/accounts/{id}/activate` com `provision_mail` - [x] `POST /billing/accounts/{id}/activate` com `provision_mail`
- [ ] Webhook `order-activated` alinhado com [035](../035-ligbox-mail-bundles-foss-openpanel/spec.md) - [x] Webhook `POST /billing/webhook/foss/order-activated`
- [x] `wizard_client.provision_mail_bundle()` → VM112
## Fase 6 — Testes E2E ## Fase 6 — Testes
- [ ] Domínio teste: wizard → company.validated → activate → FOSS+OP+Odoo - [x] Unit: `test_activation_mapper_043.py` (3)
- [ ] Idempotência retry - [x] Unit: `test_client_activation_043.py` (4) — happy path, idempotência FOSS, reject active
- [ ] RBAC NOC mascarado - [ ] E2E Roger: domínio teste wizard → activate → FOSS+OP+Odoo