feat(pos/facturapi): add organization setup flow and detailed status
- get_org_status now returns has_key, has_org_id, pending_steps, error - add find_organization_by_rfc and create_organization helpers - add /facturapi/setup endpoint to link/create Facturapi org - frontend shows detailed PAC status and setup button - support using tenant sk_user_* key when FACTURAPI_USER_KEY env is absent
This commit is contained in:
@@ -560,6 +560,60 @@ def facturapi_status():
|
|||||||
return jsonify(status)
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
|
||||||
|
@require_auth('invoicing.create')
|
||||||
|
def facturapi_setup():
|
||||||
|
"""Create or link a Facturapi organization for this tenant.
|
||||||
|
|
||||||
|
Requires FACTURAPI_USER_KEY environment variable.
|
||||||
|
Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_config = _get_issuer_config(cur)
|
||||||
|
if not tenant_config.get('rfc'):
|
||||||
|
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||||
|
|
||||||
|
result = facturapi_service.create_organization(tenant_config)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value)
|
||||||
|
VALUES ('cfdi_facturapi_org_id', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (result['org_id'],))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value)
|
||||||
|
VALUES ('cfdi_facturapi_key', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (result['api_key'],))
|
||||||
|
|
||||||
|
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None,
|
||||||
|
new_value={'org_id': result['org_id']})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'org_id': result['org_id'],
|
||||||
|
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.',
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||||
@require_auth('invoicing.view')
|
@require_auth('invoicing.view')
|
||||||
def facturapi_download(cfdi_id, doc_type):
|
def facturapi_download(cfdi_id, doc_type):
|
||||||
|
|||||||
@@ -186,28 +186,125 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||||
|
"""Resolve the Facturapi user key to use for organization management.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. FACTURAPI_USER_KEY environment variable
|
||||||
|
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key()
|
||||||
|
if user_key:
|
||||||
|
return user_key
|
||||||
|
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
|
||||||
|
if tenant_key.startswith("sk_user_"):
|
||||||
|
return tenant_key
|
||||||
|
raise FacturapiError(
|
||||||
|
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
|
||||||
|
"""Search for an existing Facturapi organization by tenant RFC.
|
||||||
|
|
||||||
|
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
|
||||||
|
Returns the organization dict or None.
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key_for_tenant(tenant_config)
|
||||||
|
|
||||||
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
|
if not rfc:
|
||||||
|
raise FacturapiError("Tenant RFC is required to search organizations")
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30)
|
||||||
|
for org in result.get("data", []):
|
||||||
|
legal = org.get("legal", {})
|
||||||
|
if (legal.get("tax_id") or "").upper() == rfc:
|
||||||
|
return org
|
||||||
|
if page >= result.get("total_pages", 1):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_organization(tenant_config: dict) -> dict:
|
||||||
|
"""Create a new Facturapi organization for the tenant and return live key.
|
||||||
|
|
||||||
|
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||||
|
Uses tenant RFC/razon_social if available.
|
||||||
|
"""
|
||||||
|
user_key = _get_user_key_for_tenant(tenant_config)
|
||||||
|
|
||||||
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
|
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||||
|
|
||||||
|
# First try to find existing org by RFC
|
||||||
|
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||||
|
if existing:
|
||||||
|
org_id = existing["id"]
|
||||||
|
else:
|
||||||
|
payload = {"name": name}
|
||||||
|
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
||||||
|
org_id = org.get("id")
|
||||||
|
if not org_id:
|
||||||
|
raise FacturapiError("Could not create organization: no id returned")
|
||||||
|
|
||||||
|
# Generate live secret key
|
||||||
|
key_resp = _request(
|
||||||
|
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
|
||||||
|
)
|
||||||
|
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||||
|
if not live_key:
|
||||||
|
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||||
|
|
||||||
|
return {"org_id": org_id, "api_key": live_key}
|
||||||
|
|
||||||
|
|
||||||
def get_org_status(tenant_config: dict) -> dict:
|
def get_org_status(tenant_config: dict) -> dict:
|
||||||
"""Return organization status: configured, has_csd, org_id."""
|
result = {
|
||||||
|
"configured": False,
|
||||||
|
"has_key": False,
|
||||||
|
"has_org_id": False,
|
||||||
|
"has_csd": False,
|
||||||
|
"org_id": None,
|
||||||
|
"legal_name": None,
|
||||||
|
"tax_id": None,
|
||||||
|
"pending_steps": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api_key = get_api_key(tenant_config)
|
api_key = get_api_key(tenant_config)
|
||||||
except FacturapiError:
|
result["has_key"] = True
|
||||||
return {"configured": False, "has_csd": False, "org_id": None}
|
except FacturapiError as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
return result
|
||||||
|
|
||||||
org_id = tenant_config.get("facturapi_org_id")
|
org_id = tenant_config.get("facturapi_org_id")
|
||||||
if not org_id:
|
if not org_id:
|
||||||
return {"configured": False, "has_csd": False, "org_id": None}
|
result["error"] = "No Facturapi organization configured"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["has_org_id"] = True
|
||||||
|
result["org_id"] = org_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
org = get_organization(org_id, api_key)
|
org = get_organization(org_id, api_key)
|
||||||
return {
|
legal = org.get("legal", {})
|
||||||
|
cert = org.get("certificate", {})
|
||||||
|
result.update({
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"org_id": org_id,
|
"has_csd": bool(cert.get("has_certificate")),
|
||||||
"has_csd": bool(org.get("certificate", {}).get("has_certificate")),
|
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||||
"legal_name": org.get("legal", {}).get("name"),
|
"tax_id": legal.get("tax_id"),
|
||||||
"tax_id": org.get("legal", {}).get("tax_id"),
|
"pending_steps": org.get("pending_steps", []),
|
||||||
}
|
})
|
||||||
except FacturapiError:
|
except FacturapiError as e:
|
||||||
return {"configured": False, "has_csd": False, "org_id": org_id}
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ─── Customers ──────────────────────────────────────────────────────────────
|
# ─── Customers ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -267,28 +267,68 @@ const Invoicing = (() => {
|
|||||||
container.innerHTML = 'Cargando...';
|
container.innerHTML = 'Cargando...';
|
||||||
try {
|
try {
|
||||||
const status = await api('/facturapi/status');
|
const status = await api('/facturapi/status');
|
||||||
if (!status.configured) {
|
|
||||||
container.innerHTML = `<p style="color:var(--color-error);">Facturapi no configurado. Configura la llave API en Configuración.</p>`;
|
if (!status.has_key) {
|
||||||
|
container.innerHTML = `<p style="color:var(--color-error);">Falta la llave API de Facturapi. Configura <code>cfdi_facturapi_key</code> o la variable <code>FACTURAPI_USER_KEY</code>.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!status.has_org_id) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<p style="color:var(--color-warning);margin-bottom:var(--space-3);">No hay organización Facturapi vinculada.</p>
|
||||||
|
<button class="btn btn--primary" onclick="Invoicing.setupFacturapi(this)">Crear / Vincular Organización</button>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let csdHtml = status.has_csd
|
||||||
|
? '<span style="color:var(--color-success);">Activo</span>'
|
||||||
|
: '<span style="color:var(--color-error);">Pendiente</span>';
|
||||||
|
|
||||||
|
let pendingHtml = '';
|
||||||
|
if (status.pending_steps && status.pending_steps.length) {
|
||||||
|
pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' +
|
||||||
|
status.pending_steps.map(s => `<li>${s.description || s.type}</li>`).join('') +
|
||||||
|
'</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Organización</div>
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Organización</div>
|
||||||
<div style="font-weight:var(--font-weight-semibold);">${status.legal_name || '-'}</div>
|
<div style="font-weight:var(--font-weight-semibold);">${escapeHtml(status.legal_name) || '-'}</div>
|
||||||
<div style="font-family:var(--font-mono);font-size:var(--text-body-sm);">${status.tax_id || ''}</div>
|
<div style="font-family:var(--font-mono);font-size:var(--text-body-sm);">${status.tax_id || ''}</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
|
||||||
<div style="font-weight:var(--font-weight-semibold);">${status.has_csd ? '<span style="color:var(--color-success);">Activo</span>' : '<span style="color:var(--color-error);">Pendiente</span>'}</div>
|
<div style="font-weight:var(--font-weight-semibold);">${csdHtml}</div>
|
||||||
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">Pasos pendientes</div>
|
||||||
|
${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
|
||||||
`;
|
`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
|
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setupFacturapi(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Configurando...';
|
||||||
|
try {
|
||||||
|
const res = await api('/facturapi/setup', { method: 'POST' });
|
||||||
|
alert('Organización vinculada: ' + res.org_id);
|
||||||
|
loadFacturapiStatus();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Crear / Vincular Organización';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Detail modal (uses modalDetalleOverlay) ----
|
// ---- Detail modal (uses modalDetalleOverlay) ----
|
||||||
async function showDetail(cfdiId) {
|
async function showDetail(cfdiId) {
|
||||||
const overlay = document.getElementById('modalDetalleOverlay');
|
const overlay = document.getElementById('modalDetalleOverlay');
|
||||||
@@ -571,7 +611,7 @@ const Invoicing = (() => {
|
|||||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
||||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||||
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
|
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
Reference in New Issue
Block a user