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:
parent
f344931925
commit
c815a32bbc
15 changed files with 983 additions and 47 deletions
|
|
@ -1,23 +1,26 @@
|
|||
0.14.1-spec043-fase1-preview
|
||||
0.14.2-spec043-complete
|
||||
|
||||
Specs:
|
||||
043-desk-client-activation-sync — Fase 1: activation-preview API + UI modal
|
||||
043-desk-client-activation-sync — Fases 1–6: preview + activate + FOSS/OP/Odoo/wizard
|
||||
040-desk-design-system-v013 — UserWizard, Access Control Hub
|
||||
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/
|
||||
|
||||
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:
|
||||
desk-v0.14.0-pre-spec043-activation — antes de qualquer código Spec 043
|
||||
desk-v0.14.1-spec043-fase1 — Fase 1 preview UI
|
||||
desk-v0.14.0-pre-spec043-activation — docs only
|
||||
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
|
||||
|
|
|
|||
|
|
@ -237,7 +237,6 @@ def build_activation_preview(
|
|||
"openpanel_username": profile.get("openpanel_username"),
|
||||
},
|
||||
"notes": (
|
||||
"Preview read-only — Fase 1 Spec 043. "
|
||||
"POST /activate implementado nas Fases 2–5."
|
||||
"Preview read-only — confirmar dados antes de Activar conta."
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
"""Billing API routes — Spec 023."""
|
||||
"""Billing API routes — Spec 023 + Spec 043."""
|
||||
|
||||
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 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
|
||||
|
|
@ -19,6 +25,21 @@ class PatchBillingBody(BaseModel):
|
|||
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")
|
||||
|
|
@ -136,6 +157,91 @@ def billing_activation_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}")
|
||||
def patch_billing_account(
|
||||
account_id: int,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ def init_schema(conn) -> None:
|
|||
external_subscription_id TEXT,
|
||||
payment_provider TEXT,
|
||||
plan_code TEXT,
|
||||
odoo_partner_id TEXT,
|
||||
provision_json TEXT,
|
||||
activated_at TEXT,
|
||||
activated_by TEXT,
|
||||
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);
|
||||
"""
|
||||
)
|
||||
_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:
|
||||
|
|
@ -85,10 +97,12 @@ def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
|
|||
"recurrence_active": bool(row["recurrence_active"]),
|
||||
"external_customer_id": row["external_customer_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"],
|
||||
"plan_code": row["plan_code"],
|
||||
"activated_at": row["activated_at"],
|
||||
"activated_by": row["activated_by"],
|
||||
"provision_log": _provision_summary(row),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"links": {
|
||||
|
|
@ -96,9 +110,27 @@ def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
|
|||
"odoo": ODOO_URL,
|
||||
},
|
||||
}
|
||||
if out.get("odoo_partner_id"):
|
||||
out["links"]["odoo_partner"] = f"{ODOO_URL}&partner_id={out['odoo_partner_id']}"
|
||||
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:
|
||||
p = dict(profile)
|
||||
if p.get("tax_id"):
|
||||
|
|
@ -226,11 +258,13 @@ def patch_account(conn, account_id: int, **fields) -> dict[str, Any] | None:
|
|||
"recurrence_active",
|
||||
"external_customer_id",
|
||||
"external_subscription_id",
|
||||
"odoo_partner_id",
|
||||
"payment_provider",
|
||||
"plan_code",
|
||||
"activated_at",
|
||||
"activated_by",
|
||||
"ticket_id",
|
||||
"provision_json",
|
||||
}
|
||||
if fields.get("recurrence_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)
|
||||
|
||||
|
||||
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]:
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) FROM billing_accounts WHERE billing_state = 'awaiting_billing_validation'"
|
||||
|
|
|
|||
262
projects/ops-desk/api/app/client_activation.py
Normal file
262
projects/ops-desk/api/app/client_activation.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""Orquestração activação cliente — Spec 043 Fases 2–5."""
|
||||
|
||||
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
|
||||
|
|
@ -42,9 +42,29 @@ def _post(path: str, payload: dict) -> dict[str, Any]:
|
|||
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:
|
||||
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:
|
||||
return None
|
||||
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:
|
||||
dom = domain.strip().lower()
|
||||
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 field in ("company", "company_vat", "email"):
|
||||
val = str(item.get(field, "")).lower()
|
||||
|
|
@ -66,6 +86,121 @@ def find_client_by_domain(domain: str) -> dict[str, Any] | 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:
|
||||
return FOSS_GROUP_BY_ROLE.get(desk_role)
|
||||
|
||||
|
|
|
|||
|
|
@ -165,11 +165,99 @@ def find_partner_by_email(email: str) -> dict[str, Any] | None:
|
|||
"res.partner",
|
||||
"search_read",
|
||||
[[("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
|
||||
|
||||
|
||||
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:
|
||||
uid, models = _client()
|
||||
rows = models.execute_kw(
|
||||
|
|
|
|||
|
|
@ -83,14 +83,17 @@ def provision_user(
|
|||
email: str,
|
||||
domain: str,
|
||||
plan_name: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
payload: dict[str, Any] = {
|
||||
"username": username.strip().lower(),
|
||||
"password": password,
|
||||
"email": email,
|
||||
"domain": domain.strip().lower(),
|
||||
"plan_name": plan_name or DEFAULT_PLAN,
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
res = client.post(f"{BRIDGE_URL}/api/users", headers=_headers(), json=payload)
|
||||
data = res.json() if res.content else {}
|
||||
|
|
@ -99,6 +102,45 @@ def provision_user(
|
|||
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]:
|
||||
payload = {"username": username.strip().lower(), "domain": domain.strip().lower()}
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
|
|
|
|||
48
projects/ops-desk/api/app/vm123/wizard_client.py
Normal file
48
projects/ops-desk/api/app/vm123/wizard_client.py
Normal 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', '')}",
|
||||
}
|
||||
150
projects/ops-desk/api/tests/test_client_activation_043.py
Normal file
150
projects/ops-desk/api/tests/test_client_activation_043.py
Normal 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()
|
||||
|
|
@ -43,9 +43,8 @@ const DeskBilling = (() => {
|
|||
</section>`;
|
||||
}
|
||||
|
||||
async function openActivationPreview(accountId, domain) {
|
||||
const planSel = document.querySelector('[data-billing-plan]');
|
||||
const plan = planSel?.value || '';
|
||||
async function openActivationPreview(accountId, domain, planCode) {
|
||||
const plan = planCode || document.querySelector('[data-billing-plan]')?.value || '';
|
||||
const qs = plan ? `?plan_code=${encodeURIComponent(plan)}` : '';
|
||||
const preview = await api(`/v1/billing/accounts/${accountId}/activation-preview${qs}`);
|
||||
const m = preview.mapped || {};
|
||||
|
|
@ -53,6 +52,7 @@ const DeskBilling = (() => {
|
|||
const op = m.openpanel || {};
|
||||
const odoo = m.odoo_partner || {};
|
||||
const meta = op.metadata || {};
|
||||
const canGo = preview.can_activate !== false;
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'billing-modal-backdrop';
|
||||
|
|
@ -67,13 +67,48 @@ const DeskBilling = (() => {
|
|||
</div>
|
||||
<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">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-billing-activate-confirm disabled title="Fase 2–5 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>
|
||||
</div>
|
||||
<p class="ticket-meta" data-billing-activate-status style="min-height:1.2em;margin-top:0.5rem"></p>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) 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) {
|
||||
|
|
@ -101,6 +136,7 @@ const DeskBilling = (() => {
|
|||
<p class="ticket-meta">
|
||||
<a href="${esc(acc.links?.fossbilling || '#')}" target="_blank" rel="noreferrer">FOSSBilling 💳</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>
|
||||
<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>` : ''}
|
||||
|
|
@ -115,8 +151,9 @@ const DeskBilling = (() => {
|
|||
const btn = e.currentTarget;
|
||||
const id = btn.getAttribute('data-billing-activate');
|
||||
const dom = btn.getAttribute('data-domain') || domain;
|
||||
const plan = backdrop.querySelector('[data-billing-plan]')?.value || '';
|
||||
try {
|
||||
await openActivationPreview(id, dom);
|
||||
await openActivationPreview(id, dom, plan);
|
||||
} catch (err) {
|
||||
alert(err.message || 'Erro ao carregar preview');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| Tag | Conteúdo |
|
||||
|-----|----------|
|
||||
| `desk-v0.14.0-pre-spec043-activation` | Baseline **antes** de implementar Fases 1–6 |
|
||||
| `desk-v0.14.1-spec043-fase1` | Após Fase 1 (preview UI + API) |
|
||||
| `desk-v0.14.2-spec043-complete` | Fases 2–6 — activate FOSS+OP+Odoo+wizard |
|
||||
|
||||
## Reverter código Desk (VM122)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Criado:** 2026-07-01
|
||||
**Solicitado por:** Roger
|
||||
**Status:** 🚧 **Fase 1 em curso** — preview UI + API; Fases 2–6 pendentes
|
||||
**Status:** ✅ **Implementação completa (Fases 1–6 código)** — aguarda teste E2E Roger na VM122
|
||||
**Prioridade:** P0 (orquestração comercial Ligbox)
|
||||
**Sistemas:** VM112 Wizard · VM122 Desk · VM123 FOSS/Odoo/OpenPanel
|
||||
|
||||
|
|
|
|||
|
|
@ -6,34 +6,36 @@
|
|||
- [x] Contrato API `desk-activate-account-api.md`
|
||||
- [x] Ligações cruzadas specs 023/024/028/035
|
||||
- [x] Registo em `SPEC-REGISTRY.md`
|
||||
- [x] `ROLLBACK.md` com tags Git
|
||||
|
||||
## Fase 1 — Desk preview UI
|
||||
- [x] `GET /billing/accounts/{id}/activation-preview`
|
||||
- [x] Botão **Activar conta** em `billing-ui.js` (só roles autorizados)
|
||||
- [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
|
||||
- [ ] `foss_client.create_client()` conforme mapa §4
|
||||
- [ ] `foss_client.create_order()` + activate hosting
|
||||
- [ ] Custom fields FOSS: `ligbox_domain`, `manager_name`, `manager_email`
|
||||
- [ ] Persistir `external_customer_id`
|
||||
- [x] `foss_client.create_client()` conforme mapa §4
|
||||
- [x] `foss_client.create_order()` + activate hosting
|
||||
- [x] Custom fields FOSS via `config` (domain, manager_*)
|
||||
- [x] Persistir `external_customer_id` + `external_subscription_id`
|
||||
|
||||
## Fase 3 — OpenPanel
|
||||
- [ ] Confirmar fluxo via módulo FOSS vs bridge directo
|
||||
- [ ] Metadata JSON mail no user hub
|
||||
- [ ] Persistir `openpanel_username` em `company_profile_json`
|
||||
- [x] Bridge directo (`provision_user_safe`) + metadata JSON
|
||||
- [x] Persistir `openpanel_username` em `company_profile_json`
|
||||
- [x] Idempotência user exists
|
||||
|
||||
## Fase 4 — Odoo empresa
|
||||
- [ ] `odoo_client.upsert_customer_partner()` conforme mapa §6
|
||||
- [ ] Coluna `odoo_partner_id` em `billing_accounts`
|
||||
- [ ] Deep-link Odoo na ficha cliente
|
||||
- [x] `odoo_client.upsert_customer_partner()` conforme mapa §6
|
||||
- [x] Coluna `odoo_partner_id` em `billing_accounts`
|
||||
- [x] Deep-link Odoo na ficha cliente
|
||||
|
||||
## Fase 5 — Wizard mail-bundle
|
||||
- [ ] `POST /billing/accounts/{id}/activate` com `provision_mail`
|
||||
- [ ] Webhook `order-activated` alinhado com [035](../035-ligbox-mail-bundles-foss-openpanel/spec.md)
|
||||
- [x] `POST /billing/accounts/{id}/activate` com `provision_mail`
|
||||
- [x] Webhook `POST /billing/webhook/foss/order-activated`
|
||||
- [x] `wizard_client.provision_mail_bundle()` → VM112
|
||||
|
||||
## Fase 6 — Testes E2E
|
||||
- [ ] Domínio teste: wizard → company.validated → activate → FOSS+OP+Odoo
|
||||
- [ ] Idempotência retry
|
||||
- [ ] RBAC NOC mascarado
|
||||
## Fase 6 — Testes
|
||||
- [x] Unit: `test_activation_mapper_043.py` (3)
|
||||
- [x] Unit: `test_client_activation_043.py` (4) — happy path, idempotência FOSS, reject active
|
||||
- [ ] E2E Roger: domínio teste wizard → activate → FOSS+OP+Odoo
|
||||
|
|
|
|||
Loading…
Reference in a new issue