Files
agent_contas/app/domain/contas/client.py

692 lines
35 KiB
Python

from __future__ import annotations
import base64
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import quote
import requests
import yaml
from .integrations.secure_pdf_crypto import encrypt_secure_pdf_value
from .parsers import parse_tim_bill_pdf
class TimApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, body: Any = None, attempts: list[dict[str, Any]] | None = None):
super().__init__(message)
self.status_code = status_code
self.body = body
self.attempts = list(attempts or [])
_INTEGRATION_DEFAULTS_CACHE: dict[str, str] | None = None
def get_tim_integration_value(*names: str, default: str = "") -> str:
"""Env > config/tim_integration_defaults.yaml > default explícito."""
global _INTEGRATION_DEFAULTS_CACHE
for name in names:
value = os.getenv(name)
if value is not None and str(value).strip():
return str(value).strip()
if _INTEGRATION_DEFAULTS_CACHE is None:
path = Path(os.getenv("TIM_INTEGRATION_DEFAULTS_PATH", "./config/tim_integration_defaults.yaml"))
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception as exc:
raise TimApiError(f"Falha ao ler defaults de integração TIM: {path}: {exc}") from exc
_INTEGRATION_DEFAULTS_CACHE = {str(k): str(v) for k, v in (raw or {}).items() if v not in (None, "")}
for name in names:
value = _INTEGRATION_DEFAULTS_CACHE.get(name)
if value is not None and str(value).strip():
return str(value).strip()
return default
class TimApiClient:
"""Thin TIM integration adapter.
This class deliberately contains no agent, LangGraph, memory, routing or LLM logic.
Those responsibilities belong to agent_framework_oci.
"""
FIXTURES = Path(__file__).with_name("fixtures")
INTEGRATION_DEFAULTS_PATH = Path(os.getenv("TIM_INTEGRATION_DEFAULTS_PATH", "./config/tim_integration_defaults.yaml"))
_integration_defaults_cache: dict[str, str] | None = None
def __init__(self) -> None:
self.mock = os.getenv("TIM_USE_MOCK_GATEWAY", "false").lower() in {"1", "true", "yes", "on"} or os.getenv("TIM_GATEWAY_MODE", "real").lower() == "mock"
self.timeout = int(os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))
self.max_retries = int(os.getenv("TIM_GATEWAY_RETRY_MAX_RETRIES", "3"))
self.backoff = float(os.getenv("TIM_GATEWAY_RETRY_BACKOFF_FACTOR", "0.5"))
def fixture(self, name: str) -> Any:
path = self.FIXTURES / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
@classmethod
def _integration_defaults(cls) -> dict[str, str]:
get_tim_integration_value("__warmup__")
return dict(_INTEGRATION_DEFAULTS_CACHE or {})
@classmethod
def _env_first(cls, *names: str, default: str = "") -> str:
return get_tim_integration_value(*names, default=default)
@classmethod
def _auth_value(
cls,
*auth_names: str,
user_names: tuple[str, ...] = (),
password_names: tuple[str, ...] = (),
) -> str:
user = cls._env_first(*user_names) if user_names else ""
password = cls._env_first(*password_names) if password_names else ""
if user and password:
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
return f"Basic {token}"
raw = cls._env_first(*auth_names)
if not raw:
return ""
low = raw.lower()
if low.startswith("basic ") or low.startswith("bearer "):
return raw
if ":" in raw and " " not in raw:
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
return raw
@staticmethod
def _url_with_msisdn(base: str, msisdn: str, *, normalize_country: bool = False) -> str:
text = str(base or "").strip()
digits = "".join(ch for ch in str(msisdn or "") if ch.isdigit())
if normalize_country and digits and not digits.startswith("55"):
digits = f"55{digits}"
value = digits or str(msisdn or "").strip()
if "{msisdn}" in text:
return text.replace("{msisdn}", quote(value))
return f"{text.rstrip('/')}/{quote(value)}" if text else value
def _headers(self, *, client_id: str | None = None, auth: str | None = None, extra: dict[str, str] | None = None) -> dict[str, str]:
h = {"Content-Type": "application/json", "Accept": "application/json"}
if client_id:
h["client_id"] = client_id
if auth:
h["Authorization"] = auth
if extra:
h.update({k: v for k, v in extra.items() if v not in (None, "")})
return h
def request(self, method: str, url: str, *, payload: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: int | None = None, attempt_log: list[dict[str, Any]] | None = None) -> Any:
if not url:
raise TimApiError("Endpoint TIM não configurado")
last: Exception | None = None
attempts = max(1, self.max_retries)
for attempt in range(attempts):
started = time.monotonic()
try:
resp = requests.request(method, url, json=payload, params=params, headers=headers or {}, timeout=timeout or self.timeout)
if resp.status_code in {204, 205}:
if attempt_log is not None:
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
return {"status": resp.status_code, "message": "OK"}
resp.raise_for_status()
if attempt_log is not None:
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
if not resp.content:
return {"status": resp.status_code, "message": "OK"}
ctype = resp.headers.get("content-type", "").lower()
if "json" in ctype:
return resp.json()
return {"status": resp.status_code, "raw_content": resp.content, "content_type": ctype}
except requests.RequestException as exc:
last = exc
if attempt_log is not None:
response = getattr(exc, "response", None)
attempt_log.append({"attempt": attempt + 1, "success": False, "status_code": getattr(response, "status_code", None), "latency_ms": int((time.monotonic()-started)*1000), "error": str(exc), "api_url": url})
if attempt >= attempts - 1:
break
time.sleep(self.backoff * (2 ** attempt))
response = getattr(last, "response", None)
status_code = getattr(response, "status_code", None)
body = None
if response is not None:
try:
body = response.json()
except Exception:
body = getattr(response, "text", None)
raise TimApiError(str(last), status_code=status_code, body=body, attempts=list(attempt_log or []))
@staticmethod
def _attach_transport(result: Any, *, operation: str, attempts: list[dict[str, Any]]) -> Any:
metadata = {
"rct_operation": operation,
"attempts": list(attempts),
"api_response_payload": result if isinstance(result, (dict, list, str, int, float, bool)) else str(result),
}
if isinstance(result, dict):
out = dict(result)
out["_transport"] = metadata
return out
return {"data": result, "_transport": metadata}
def consultar_faturas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("complete_invoices")
url = self._env_first("TIM_COMPLETE_INVOICES_URL")
return self.request(
"POST", url, payload={"msisdn": msisdn},
headers=self._headers(
client_id=None, auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", default="30")),
)
def billing_analysis(self, msisdn: str, **context: Any) -> Any:
if self.mock:
return self.fixture("divergencia")
base = self._env_first("TIM_DIVERGENCIA_URL").rstrip("/")
url = f"{base}/{quote(msisdn)}"
auth = self._auth_value(
"TIM_DIVERGENCIA_AUTH", "TIM_DIVERGENCE_AUTH",
user_names=("TIM_DIVERGENCIA_USER", "TIM_DIVERGENCIA_USERNAME", "TIM_DIVERGENCE_USER", "TIM_DIVERGENCE_USERNAME"),
password_names=("TIM_DIVERGENCIA_PASSWORD", "TIM_DIVERGENCIA_PASS", "TIM_DIVERGENCE_PASSWORD", "TIM_DIVERGENCE_PASS"),
)
headers = self._headers(client_id=None, auth=auth, extra={
"clientID": self._env_first("TIM_DIVERGENCIA_CLIENT_ID", ),
})
params = {"channel": context.get("channel") or self._env_first("TIM_DIVERGENCIA_CHANNEL")}
attempts: list[dict[str, Any]] = []
try:
result = self.request("GET", url, params=params, headers=headers, timeout=int(self._env_first("TIM_DIVERGENCIA_TIMEOUT", "TIM_DIVERGENCE_TIMEOUT", default="120")), attempt_log=attempts)
return self._attach_transport(result, operation="base_conhecimento", attempts=attempts)
except TimApiError as exc:
if not exc.attempts:
exc.attempts = attempts
raise
def consultar_vas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("query_vas")
base = self._env_first("TIM_URL_CONSULTA_VAS", "TIM_CONSULTA_URL")
url = self._url_with_msisdn(base, msisdn, normalize_country=True)
return self.request(
"GET", url,
headers=self._headers(
client_id=None, auth=self._env_first("TIM_QUERY_AUTH", "TIM_CONSULTA_AUTH"),
extra={"clientId": self._env_first("TIM_CONSULTA_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_QUERY_TIMEOUT", "TIM_CONSULTA_TIMEOUT", default="30")),
)
def historico_vas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("vas_history")
base = os.getenv("TIM_VAS_HISTORY_URL", "")
sep = "&" if "?" in base else "?"
url = f"{base}{sep}msisdn={quote(msisdn)}"
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_VAS_HISTORY_AUTH", ""),
extra={
"clientId": self._env_first("TIM_VAS_HISTORY_CLIENT_ID", ),
"messageId": os.getenv("TIM_VAS_HISTORY_MESSAGE_ID", "") or str(uuid.uuid4()),
},
)
return self.request("GET", url, headers=headers, timeout=int(os.getenv("TIM_VAS_HISTORY_TIMEOUT", "30")))
def bloquear_vas(self, msisdn: str, service: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("block_vas")
url = self._env_first("TIM_URL_BLOQUEIO_VAS", "TIM_BLOQUEIO_URL")
digits = "".join(ch for ch in str(msisdn) if ch.isdigit())
normalized = digits[2:] if len(digits) == 13 else digits
app_id = str(service.get("appId") or service.get("app_id") or "")
csp_id = str(service.get("cspId") or service.get("csp_id") or self._env_first("TIM_DEFAULT_CSP_ID"))
op = self._env_first("TIM_BLOCK_OPERATION_TYPE", "TIM_BLOQUEIO_OPERATION_TYPE", default="block")
base = {"Customer": {"Msisdn": normalized}, "AppId": app_id, "CspId": csp_id, "TypeOperation": op}
payloads = [
{"customer": {"msisdn": normalized}, "appId": app_id, "cspId": csp_id, "typeOperation": op},
{"input": base, "Input": base},
{"vasBlock": {"msisdn": normalized, "appId": app_id, "cspId": csp_id, "type": op}},
]
mode = self._env_first("TIM_BLOCK_PAYLOAD_MODE", "TIM_BLOQUEIO_PAYLOAD_MODE", default="auto").lower()
if mode == "vasblock": payloads = [payloads[2], payloads[0]]
elif mode == "input": payloads = [payloads[1], payloads[0]]
elif mode == "pmid": payloads = [payloads[0], payloads[1]]
headers = self._headers(
client_id=None,
auth=self._env_first("TIM_BLOCK_AUTH", "TIM_BLOQUEIO_AUTH"),
extra={
"Accept-Encoding": self._env_first("TIM_BLOCK_ACCEPT_ENCODING", "TIM_BLOQUEIO_ACCEPT_ENCODING", default="gzip,deflate"),
"clientId": self._env_first("TIM_BLOCK_CLIENT_ID", "TIM_BLOQUEIO_CLIENT_ID", ),
"messageId": str(uuid.uuid4()),
},
)
last = None
for index, payload in enumerate(payloads):
try:
return self.request("POST", url, payload=payload, headers=headers, timeout=int(self._env_first("TIM_BLOCK_TIMEOUT", "TIM_BLOQUEIO_TIMEOUT", default="30")))
except TimApiError as exc:
last = exc
if exc.status_code != 400 or index >= len(payloads) - 1:
raise
raise last or TimApiError("Bloqueio VAS falhou")
def cancelar_vas(self, msisdn: str, service: dict[str, Any], *, protocol: str = "") -> Any:
if self.mock:
return self.fixture("cancel_vas")
url = self._env_first("TIM_CANCELLATION_URL", "TIM_CANCELAMENTO_URL")
payload = {
"channel": self._env_first("TIM_CANCELAMENTO_CHANNEL"),
"msisdn": msisdn,
"appId": str(service.get("appId") or service.get("app_id") or ""),
"cspId": str(service.get("cspId") or service.get("csp_id") or self._env_first("TIM_DEFAULT_CSP_ID")),
"interactionProtocol": protocol,
}
headers = self._headers(
client_id=None,
auth=self._env_first("TIM_CANCELLATION_AUTH", "TIM_CANCELAMENTO_AUTH"),
extra={
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", self._env_first("TIM_DEFAULT_CLIENT_ID")),
"messageId": str(uuid.uuid4()),
"AuthorizationOAM": self._env_first("TIM_CANCELLATION_AUTH_OAM", "TIM_CANCELAMENTO_AUTH_OAM", "TIM_CANCELAMENTO_AUTHORIZATION_OAM"),
"Cn_field": self._env_first("TIM_CANCELLATION_CN_FIELD", "TIM_CANCELAMENTO_CN_FIELD"),
"Type_field": self._env_first("TIM_CANCELLATION_TYPE_FIELD", "TIM_CANCELAMENTO_TYPE_FIELD"),
},
)
attempts: list[dict[str, Any]] = []
result = self.request("DELETE", url, payload=payload, headers=headers, timeout=int(self._env_first("TIM_CANCELLATION_TIMEOUT", "TIM_CANCELAMENTO_TIMEOUT", default="30")), attempt_log=attempts)
return self._attach_transport(result, operation="cancela_vas", attempts=attempts)
def contrato(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("contrato")
base = os.getenv("TIM_CONTRATO_URL", "").rstrip("/")
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CONTRATO_AUTH", ""),
extra={"clientId": self._env_first("TIM_CONTRATO_CLIENT_ID", )},
)
return self.request("GET", f"{base}/{quote(msisdn)}", headers=headers, timeout=int(os.getenv("TIM_CONTRATO_TIMEOUT", "30")))
def profile_full(self, msisdn: str) -> Any:
if self.mock:
# contract fixture carries representative customer identity in local mode.
return self.fixture("contrato")
url = self._url_with_msisdn(self._env_first("TIM_PROFILE_FULL_URL"), msisdn)
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": self._env_first("TIM_PROFILE_FULL_CLIENT_ID")},
)
return self.request("GET", url, headers=headers, timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")))
def abrir_protocolo(self, payload: dict[str, Any]) -> Any:
"""Registra protocolo V2 preservando o contrato externo do Contas original.
As workflow actions trabalham com um payload lógico/achatado. O adapter é
responsável por converter esse modelo para o contrato PMid/Siebel, sem
contaminar o domínio ou o WorkflowRuntime com detalhes HTTP.
"""
if self.mock:
return self.fixture("protocol")
data = dict(payload or {})
msisdn = str(data.get("msisdn") or data.get("assetId") or "")
service_status = str(data.get("requestStatus") or data.get("status") or "")
message_id = str(
data.get("messageId")
or data.get("interactionCallId")
or data.get("ura_call_id")
or data.get("session_id")
or uuid.uuid4()
)
body = {
"socialSecNo": str(data.get("socialSecNo") or data.get("social_sec_no") or ""),
"channel": self._env_first("TIM_PROTOCOL_CHANNEL"),
"customerId": str(data.get("customerId") or data.get("customer_id") or ""),
"assetId": str(data.get("assetId") or data.get("asset_id") or msisdn),
"customerName": str(data.get("customerName") or data.get("customer_name") or ""),
"customerEmail": str(data.get("customerEmail") or data.get("customer_email") or ""),
"customerPhone1": str(data.get("customerPhone1") or data.get("customer_phone1") or ""),
"accessType": str(data.get("accessType") or data.get("access_type") or ""),
"serviceRequest": {
"userId": str(data.get("serviceRequestUserId") or data.get("service_request_user_id") or ""),
"reason1": str(data.get("reason1") or ""),
"reason2": str(data.get("reason2") or ""),
"reason3": str(data.get("reason3") or ""),
"status": service_status,
"notes": str(data.get("serviceRequestNotes") or data.get("service_request_notes") or data.get("notes") or ""),
"type": str(data.get("type") or "CLIENTE"),
},
"interaction": {
"protocol": str(data.get("interactionProtocol") or data.get("interaction_protocol") or ""),
"flagSms": True,
"source": self._env_first("TIM_DEFAULT_SOURCE"),
"callId": str(data.get("interactionCallId") or data.get("interaction_call_id") or ""),
"crmSource": "Siebel Pós",
"reasonId": str(data.get("interactionReasonId") or data.get("interaction_reason_id") or ""),
"amount": str(data.get("interactionAmount") or data.get("interaction_amount") or ""),
"directionContact": str(data.get("directionContact") or data.get("direction_contact") or "FROM-CLIENT"),
"requestFlag": False,
"requestSla": str(data.get("interactionRequestSla") or data.get("interaction_request_sla") or ""),
"status": str(data.get("status") or service_status),
},
}
headers = self._headers(
client_id=None,
auth=str(data.get("Authorization") or os.getenv("TIM_PROTOCOL_AUTH", "")),
extra={
"clientId": str(data.get("clientId") or data.get("client_id") or self._env_first("TIM_PROTOCOL_CLIENT_ID")),
"messageId": message_id,
"Authorizationoam": str(data.get("authorization_oam") or os.getenv("TIM_PROTOCOL_AUTHORIZATION_OAM", "")),
"Cn_field": str(data.get("cn_field") or os.getenv("TIM_PROTOCOL_CN_FIELD", "")),
"Type_field": str(data.get("type_field") or os.getenv("TIM_PROTOCOL_TYPE_FIELD", "")),
},
)
attempts: list[dict[str, Any]] = []
result = self.request(
"POST",
os.getenv("TIM_PROTOCOL_URL", ""),
payload=body,
headers=headers,
timeout=int(os.getenv("TIM_PROTOCOL_TIMEOUT", "30")),
attempt_log=attempts,
)
operation = str(data.get("rct_operation") or "")
return self._attach_transport(result, operation=operation, attempts=attempts) if operation else result
def contestar(self, payload: dict[str, Any]) -> Any:
if self.mock:
# The mock must behave like the real provider for the current request.
# Returning the whole static fixture leaked unrelated invoice items into
# a one-item transaction and also exposed contradictory fixture fields.
fixture = self.fixture("contestacao_tool")
provider = fixture.get("body") if isinstance(fixture, dict) and isinstance(fixture.get("body"), dict) else fixture
provider = dict(provider or {})
requested = [x for x in (payload.get("items") or []) if isinstance(x, dict)]
fixture_rows = [x for x in (provider.get("itemsResponse") or provider.get("items_response") or []) if isinstance(x, dict)]
def norm(value: Any) -> str:
import unicodedata
text = unicodedata.normalize("NFKD", str(value or "").casefold())
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return " ".join("".join(ch if ch.isalnum() else " " for ch in text).split())
selected: list[dict[str, Any]] = []
for req in requested:
name = str(req.get("itemName") or req.get("item_name") or req.get("name") or "").strip()
row = next((dict(x) for x in fixture_rows if norm(x.get("itemName") or x.get("item_name")) == norm(name)), None)
if row is None:
row = {
"correctAccountStatus": "NAO_CRIAR",
"itemName": name,
"message": "Item não existe na fatura com o valor informado",
"status": "NAO_INICIADA",
}
selected.append(row)
if requested:
provider["itemsResponse"] = selected
provider["sr"] = str(payload.get("sr") or provider.get("sr") or "")
# Keep useful provider response fields, but never return the fixture's
# stale top-level normalized/result/protocol fields as if they came
# from the remote API.
if isinstance(fixture, dict):
for key in ("barcode", "codigo_boleto", "contestation_id", "contestationId", "manualContaCertaIndicator"):
if key in fixture and key not in provider:
provider[key] = fixture[key]
return provider
data = dict(payload)
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", ))
data.setdefault("customerIdCurrent", data.get("customerId") or "")
data.setdefault("customerType", "2")
data.setdefault("customerStatus", "1")
status_map = {"ABERTA": "0", "ABERTO": "0", "OPEN": "0", "FECHADA": "1", "FECHADO": "1", "CLOSED": "1"}
raw_status = str(data.get("invoiceStatus") or "").strip()
data["invoiceStatus"] = status_map.get(raw_status.upper(), raw_status or "0")
data.setdefault("invoiceAmountOpen", "0")
data.setdefault("invoiceAmount", "0")
due = "".join(ch for ch in str(data.get("invoiceDueDate") or "") if ch.isdigit())
if len(due) == 8 and str(data.get("invoiceDueDate") or "").startswith(tuple(str(y) for y in range(19, 22))):
# YYYYMMDD already normalized
pass
elif len(due) == 8:
# common ddMMyyyy -> yyyyMMdd
due = due[4:]+due[2:4]+due[:2]
data["invoiceDueDate"] = due
data.setdefault("contestationType", "0")
data.setdefault("adjustReason", "SERVICO_NAO_SOLICITADO")
data.setdefault("observation", data.pop("description", ""))
data.setdefault("refundOption", "0")
data.setdefault("doubleRefund", False)
data.setdefault("manualContaCertaIndicator", False)
client_id = str(data.pop("clientId", "") or self._env_first("TIM_CUSTOMER_CONTESTATION_CLIENT_ID"))
message_id = str(data.pop("messageId", "") or uuid.uuid4())
user_id = str(data.get("userId") or self._env_first("TIM_DEFAULT_CLIENT_ID")).upper()
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CUSTOMER_CONTESTATION_AUTH", ""),
extra={"clientId": client_id, "messageId": message_id, "X-Agent-Id": user_id},
)
attempts: list[dict[str, Any]] = []
result = self.request("POST", os.getenv("TIM_CUSTOMER_CONTESTATION_URL", ""), payload=data, headers=headers, timeout=int(os.getenv("TIM_CUSTOMER_CONTESTATION_TIMEOUT", "30")), attempt_log=attempts)
return self._attach_transport(result, operation="contestacao", attempts=attempts)
def status_sr(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("service_request_status")
data = dict(payload)
service_request = data.get("serviceRequest") if isinstance(data.get("serviceRequest"), dict) else {
"status": data.get("status") or "",
"notes": data.get("notes") or "",
"protocolNumber": data.get("protocolNumber") or data.get("protocol") or "",
"auditResult": data.get("auditResult") or "",
"auditReasonResult": data.get("auditReasonResult") or "",
"auditConvenienceTime": data.get("auditConvenienceTime") or "",
"auditSubStatus": data.get("auditSubStatus") or "",
}
for key in ("reason1", "reason2", "reason3", "auditChecks", "attachments"):
if data.get(key): service_request[key] = data[key]
body = {"channel": data.get("channel") or self._env_first("TIM_DEFAULT_CLIENT_ID"), "serviceRequest": service_request}
for key in ("msisdn", "customers", "date"):
if data.get(key): body[key] = data[key]
message_id = str(data.get("messageId") or data.get("ura_call_id") or data.get("session_id") or uuid.uuid4())
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTH", ""),
extra={
"clientId": str(data.get("clientId") or self._env_first("TIM_SERVICE_REQUEST_STATUS_CLIENT_ID")),
"messageId": message_id,
"Authorizationoam": data.get("authorization_oam") or os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTHORIZATION_OAM", ""),
"Cn_field": data.get("cn_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CN_FIELD", ""),
"Type_field": data.get("type_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_TYPE_FIELD", ""),
},
)
attempts: list[dict[str, Any]] = []
result = self.request(
"POST", os.getenv("TIM_SERVICE_REQUEST_STATUS_URL", ""), payload=body, headers=headers,
timeout=int(os.getenv("TIM_SERVICE_REQUEST_STATUS_TIMEOUT", "30")), attempt_log=attempts
)
return self._attach_transport(result, operation="", attempts=attempts)
def tracking(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("tracking_activities")
data = dict(payload)
invoice = data.get("invoice") if isinstance(data.get("invoice"), dict) else {
"emissionDate": data.get("invoiceEmissionDate") or "",
"expirationDate": data.get("invoiceExpirationDate") or "",
"number": data.get("invoiceNumber") or "",
"status": data.get("invoiceStatus") or "",
"openAmount": data.get("invoiceOpenAmount") or "",
"totalAmount": data.get("invoiceTotalAmount") or "",
}
body = {
"channel": data.get("channel") or self._env_first("TIM_TRACKING_ACTIVITIES_CHANNEL"),
"customer": {"socialSecNo": data.get("socialSecNo") or "", "msisdn": data.get("msisdn") or ""},
"protocolNumber": data.get("protocolNumber") or "",
"invoice": invoice,
"activity": {
"type": data.get("activityType") or "",
"status": data.get("activityStatus") or "",
"id": data.get("activityId") or "",
},
"user": {"login": data.get("userLogin") or self._env_first("TIM_TRACKING_ACTIVITIES_USER_LOGIN")},
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_TRACKING_ACTIVITIES_AUTH", ""),
extra={"clientId": data.get("clientId") or self._env_first("TIM_TRACKING_ACTIVITIES_CLIENT_ID")},
)
return self.request("POST", os.getenv("TIM_TRACKING_ACTIVITIES_URL", ""), payload=body, headers=headers, timeout=int(os.getenv("TIM_TRACKING_ACTIVITIES_TIMEOUT", "30")))
def sms(self, msisdn: str, message: str, **context: Any) -> Any:
if self.mock:
return self.fixture("sms")
payload: dict[str, Any] = {
"msisdn": msisdn,
"senderAddress": context.get("sender_address") or os.getenv("TIM_SMS_SENDER_ADDRESS", "324"),
"senderName": context.get("sender_name") or self._env_first("TIM_SMS_SENDER_NAME"),
"message": message,
"longURL": context.get("long_url") or message,
}
if context.get("notify_url"):
payload["receiptRequest"] = {"notifyURL": context["notify_url"]}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_SMS_AUTH", ""),
extra={"clientId": self._env_first("TIM_SMS_CLIENT_ID")},
)
attempts: list[dict[str, Any]] = []
result = self.request("POST", os.getenv("TIM_SMS_URL", ""), payload=payload, headers=headers, timeout=int(os.getenv("TIM_SMS_TIMEOUT", "30")), attempt_log=attempts)
return self._attach_transport(result, operation="sgr_codbar", attempts=attempts)
def profile_bill(self, msisdn: str) -> Any:
"""Perfil de faturamento usando o mesmo contrato CompleteInvoices do original."""
if self.mock:
return self.fixture("profile_bill")
url = self._env_first("TIM_COMPLETE_INVOICES_URL", "TIM_URL_PERFIL_FATURA")
return self.request(
"POST",
url,
payload={"msisdn": msisdn},
headers=self._headers(
client_id=None,
auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH", "TIM_PROFILE_BILL_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", "TIM_PROFILE_BILL_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", "TIM_PROFILE_BILL_TIMEOUT", default="30")),
)
def line_info(self, msisdn: str) -> Any:
"""Consulta dados cadastrais da linha/dependente e preserva o CPF correto."""
if self.mock:
payload = self.fixture("contrato")
else:
url = self._url_with_msisdn(self._env_first("TIM_PROFILE_FULL_URL"), msisdn)
payload = self.request(
"GET",
url,
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": self._env_first("TIM_PROFILE_FULL_CLIENT_ID")},
),
timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")),
)
def extract(value: Any) -> str:
if not isinstance(value, dict):
return ""
direct = value.get("socialSecNo")
if direct not in (None, ""):
return str(direct).strip()
for key in ("customer", "contract", "billingProfile", "subscriber"):
found = extract(value.get(key))
if found:
return found
return ""
return {"social_sec_no": extract(payload), "raw": payload if isinstance(payload, dict) else {}}
def bill_pdf(
self,
msisdn: str,
invoice_id: str,
customer_id: str,
*,
include_danfe: bool = False,
output: str = "",
) -> Any:
"""Recupera a fatura detalhada via POST e normaliza seu PDF.
Esta operação é distinta de ``secure_pdf`` e corresponde ao BillPdfCommand
original, usado por pró-rata, matching e explicação detalhada de fatura.
"""
if self.mock:
parsed = self.fixture("invoice_pdf_include_danfe_true" if include_danfe else "invoice_pdf_include_danfe_false")
return {
"status": "SUCCESS",
"message": "Fatura recuperada com sucesso",
"file_content": None,
"file_name": output or "antiga.pdf",
"parsed_content": parsed,
}
url = os.getenv("TIM_BILL_PDF_URL", "") or os.getenv("TIM_SECURE_PDF_URL", "") or os.getenv("TIM_URL_INVOICE_RECOVER", "")
payload = {
"invoiceId": encrypt_secure_pdf_value(invoice_id),
"customerId": encrypt_secure_pdf_value(customer_id),
"invoiceType": "DETALHADA",
}
headers = self._headers(
client_id=None,
auth=self._env_first("TIM_BILL_PDF_AUTH", "TIM_SECURE_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={
"clientId": self._env_first("TIM_BILL_PDF_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", ),
"Accept": "application/pdf",
},
)
result = self.request(
"POST",
url,
payload=payload,
headers=headers,
timeout=int(self._env_first("TIM_BILL_PDF_TIMEOUT", "TIM_SECURE_PDF_TIMEOUT", "TIM_INVOICE_RECOVER_TIMEOUT", default="30")),
)
raw_content = result.get("raw_content") if isinstance(result, dict) else None
parsed = result.get("parsed_content") if isinstance(result, dict) else None
if raw_content and parsed is None:
parsed = parse_tim_bill_pdf(raw_content, include_danfe=include_danfe)
return {
"status": "SUCCESS" if raw_content or parsed is not None else str((result or {}).get("status") if isinstance(result, dict) else ""),
"message": "Fatura recuperada com sucesso" if raw_content or parsed is not None else "Resposta de fatura sem conteúdo",
"file_content": raw_content,
"file_name": output or "antiga.pdf",
"parsed_content": parsed,
"raw": result,
}
def secure_pdf(self, msisdn: str, invoice_id: str, customer_id: str = "") -> Any:
if self.mock:
return self.fixture("invoice_pdf_include_danfe_false")
url = os.getenv("TIM_URL_INVOICE_RECOVER", "")
params = {
"invoiceId": encrypt_secure_pdf_value(invoice_id),
"msisdn": encrypt_secure_pdf_value(msisdn),
"customerId": encrypt_secure_pdf_value(customer_id),
}
return self.request(
"GET",
url,
params=params,
headers=self._headers(
client_id=None,
auth=self._env_first("TIM_SECURE_PDF_AUTH", "TIM_BILL_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={"clientid": self._env_first("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", )},
),
timeout=int(os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30")),
)