Facturapi: fix legal payload (name/legal_name/tax_system, address, no tax_id), reuse local org, status via user key; add fiscal address fields; embed Carta Manifiesto iframe
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

This commit is contained in:
2026-07-13 06:35:36 +00:00
parent b2f23ef3cf
commit 1cce81b75f
6 changed files with 209 additions and 26 deletions

View File

@@ -761,6 +761,12 @@ def get_business():
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'cp': cfg.get('tenant_cp', ''),
'direccion': cfg.get('tenant_direccion', ''),
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
'numero_interior': cfg.get('tenant_numero_interior', ''),
'colonia': cfg.get('tenant_colonia', ''),
'ciudad': cfg.get('tenant_ciudad', ''),
'municipio': cfg.get('tenant_municipio', ''),
'estado': cfg.get('tenant_estado', ''),
'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''),
})
@@ -778,6 +784,12 @@ def update_business():
'regimen_fiscal': 'tenant_regimen_fiscal',
'cp': 'tenant_cp',
'direccion': 'tenant_direccion',
'numero_exterior': 'tenant_numero_exterior',
'numero_interior': 'tenant_numero_interior',
'colonia': 'tenant_colonia',
'ciudad': 'tenant_ciudad',
'municipio': 'tenant_municipio',
'estado': 'tenant_estado',
'telefono': 'tenant_telefono',
'email': 'tenant_email',
# Tax params (also keep cfdi_* aliases in sync)

View File

@@ -46,6 +46,13 @@ def _get_issuer_config(cur, branch_id=None):
"razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"),
"direccion": config.get("tenant_direccion", ""),
"exterior": config.get("tenant_numero_exterior", ""),
"interior": config.get("tenant_numero_interior", ""),
"colonia": config.get("tenant_colonia", ""),
"ciudad": config.get("tenant_ciudad", ""),
"municipio": config.get("tenant_municipio", ""),
"estado": config.get("tenant_estado", ""),
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),

View File

@@ -216,11 +216,27 @@ def create_organization(tenant_config: dict) -> dict:
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:
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
local_org_id = _get_org_id(tenant_config)
if local_org_id:
try:
get_organization(local_org_id, user_key)
org_id = local_org_id
except FacturapiError:
local_org_id = None
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
if not local_org_id and rfc:
existing = find_organization_by_rfc(tenant_config)
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")
elif not local_org_id:
payload = {"name": name}
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
org_id = org.get("id")
@@ -245,27 +261,49 @@ def create_organization(tenant_config: dict) -> dict:
def _build_legal_payload(tenant_config: dict) -> dict:
"""Build Facturapi /organizations/{id}/legal payload from tenant config."""
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
by this endpoint (it is set from the CSD or from the organization profile).
"""
rfc = (tenant_config.get("rfc") or "").upper().strip()
legal_name = (tenant_config.get("razon_social") or "").strip()
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
zip_code = (tenant_config.get("cp") or tenant_config.get("tenant_cp") or "").strip() or "00000"
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
if not rfc or not legal_name:
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
name = legal_name or rfc or "Nexus"
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
exterior = (
(tenant_config.get("exterior") or "").strip()
or (tenant_config.get("numero_exterior") or "").strip()
or "S/N"
)
address = {
"zip": zip_code,
"street": street,
"exterior": exterior,
}
optional_address_fields = {
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
"neighborhood": (tenant_config.get("colonia") or "").strip(),
"city": (tenant_config.get("ciudad") or "").strip(),
"municipality": (tenant_config.get("municipio") or "").strip(),
"state": (tenant_config.get("estado") or "").strip(),
}
for key, value in optional_address_fields.items():
if value:
address[key] = value
return {
"tax_id": rfc,
"name": name,
"legal_name": legal_name,
"tax_system": tax_system,
"address": {
"zip": zip_code,
"street": (tenant_config.get("direccion") or "").strip(),
"exterior": "",
"interior": "",
"neighborhood": "",
"city": "",
"municipality": "",
"state": "",
},
"address": address,
}
@@ -279,6 +317,22 @@ def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
def _get_status_key(tenant_config: dict) -> str | None:
"""Return the best key for read-only organization status checks.
Prefer the Facturapi user key because the live secret key cannot read
organization metadata until the org is production-ready.
"""
user = _get_user_key()
if user:
return user
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
return _get_secret_key(tenant_config)
def get_org_status(tenant_config: dict) -> dict:
result = {
"configured": False,
@@ -292,12 +346,12 @@ def get_org_status(tenant_config: dict) -> dict:
"error": None,
}
try:
api_key = get_api_key(tenant_config)
result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
has_secret = bool(_get_secret_key(tenant_config))
has_user = bool(_get_user_key_for_tenant(tenant_config))
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
result["has_key"] = True
org_id = _get_org_id(tenant_config)
if not org_id:
@@ -307,6 +361,11 @@ def get_org_status(tenant_config: dict) -> dict:
result["has_org_id"] = True
result["org_id"] = org_id
api_key = _get_status_key(tenant_config)
if not api_key:
result["error"] = "No Facturapi key available"
return result
def _fetch():
org = get_organization(org_id, api_key)
legal = org.get("legal", {})

View File

@@ -397,6 +397,13 @@ const Invoicing = (() => {
const cpEl = document.getElementById('cp-fiscal');
const razonEl = document.getElementById('razon-social');
const regimenEl = document.getElementById('regimen-fiscal');
const direccionEl = document.getElementById('direccion-fiscal');
const exteriorEl = document.getElementById('numero-exterior');
const interiorEl = document.getElementById('numero-interior');
const coloniaEl = document.getElementById('colonia-fiscal');
const ciudadEl = document.getElementById('ciudad-fiscal');
const municipioEl = document.getElementById('municipio-fiscal');
const estadoEl = document.getElementById('estado-fiscal');
if (!rfcEl || !cpEl || !razonEl || !regimenEl) return;
try {
@@ -406,6 +413,13 @@ const Invoicing = (() => {
rfcEl.value = data.rfc || '';
cpEl.value = data.cp || '';
razonEl.value = data.razon_social || '';
if (direccionEl) direccionEl.value = data.direccion || '';
if (exteriorEl) exteriorEl.value = data.numero_exterior || '';
if (interiorEl) interiorEl.value = data.numero_interior || '';
if (coloniaEl) coloniaEl.value = data.colonia || '';
if (ciudadEl) ciudadEl.value = data.ciudad || '';
if (municipioEl) municipioEl.value = data.municipio || '';
if (estadoEl) estadoEl.value = data.estado || '';
const regimen = data.regimen_fiscal || '601';
regimenEl.value = regimen + ' — ' + (regimenEl.querySelector('option[value^="' + regimen + '"')?.textContent.split('—')[1]?.trim() || '');
// If exact value not matched, leave first option selected by SAT code prefix
@@ -425,6 +439,13 @@ const Invoicing = (() => {
const razon_social = (document.getElementById('razon-social')?.value || '').trim();
const regimenValue = document.getElementById('regimen-fiscal')?.value || '601';
const regimen_fiscal = regimenValue.split('—')[0].trim();
const direccion = (document.getElementById('direccion-fiscal')?.value || '').trim();
const numero_exterior = (document.getElementById('numero-exterior')?.value || '').trim();
const numero_interior = (document.getElementById('numero-interior')?.value || '').trim();
const colonia = (document.getElementById('colonia-fiscal')?.value || '').trim();
const ciudad = (document.getElementById('ciudad-fiscal')?.value || '').trim();
const municipio = (document.getElementById('municipio-fiscal')?.value || '').trim();
const estado = (document.getElementById('estado-fiscal')?.value || '').trim();
const statusEl = document.getElementById('emisor-save-status');
if (!rfc || !razon_social || !cp) {
@@ -436,7 +457,11 @@ const Invoicing = (() => {
const res = await fetch('/pos/api/config/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ rfc, razon_social, regimen_fiscal, cp, cfdi_regimen_fiscal: regimen_fiscal, cfdi_serie: 'A' })
body: JSON.stringify({
rfc, razon_social, regimen_fiscal, cp,
direccion, numero_exterior, numero_interior, colonia, ciudad, municipio, estado,
cfdi_regimen_fiscal: regimen_fiscal, cfdi_serie: 'A'
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
@@ -483,6 +508,13 @@ const Invoicing = (() => {
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
}
function reloadManifiesto() {
const iframe = document.getElementById('manifiesto-iframe');
if (iframe) {
iframe.src = iframe.src;
}
}
function updateFileLabels() {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
@@ -836,7 +868,7 @@ const Invoicing = (() => {
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
uploadCsd, resetCsdForm, reloadManifiesto,
filterFacturas, exportFacturasCSV, exportNotasCSV,
newCreditNote, newPaymentComplement,
};

View File

@@ -6,7 +6,7 @@
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const VERSION = 41;
const VERSION = 43;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [

View File

@@ -757,6 +757,41 @@
<span class="form-hint">Régimen del emisor según el SAT</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="direccion-fiscal">Calle</label>
<input class="form-input" id="direccion-fiscal" type="text" value="" placeholder="Ej: Av. Insurgentes Sur" />
</div>
<div class="form-field">
<label class="form-label" for="numero-exterior">Número Exterior</label>
<input class="form-input" id="numero-exterior" type="text" value="" placeholder="Ej: 123" />
</div>
<div class="form-field">
<label class="form-label" for="numero-interior">Número Interior</label>
<input class="form-input" id="numero-interior" type="text" value="" placeholder="Ej: 4B" />
</div>
<div class="form-field">
<label class="form-label" for="colonia-fiscal">Colonia</label>
<input class="form-input" id="colonia-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="ciudad-fiscal">Ciudad</label>
<input class="form-input" id="ciudad-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="municipio-fiscal">Municipio / Alcaldía</label>
<input class="form-input" id="municipio-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="estado-fiscal">Estado</label>
<input class="form-input" id="estado-fiscal" type="text" value="" />
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);align-items:center;">
@@ -867,6 +902,44 @@
</div>
</div>
<!-- CARTA MANIFIESTO -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
<svg viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span class="config-section__title">Carta Manifiesto (SAT)</span>
</div>
<div class="config-section__body" id="manifiesto-panel">
<p style="color:var(--color-text-muted); margin-bottom:var(--space-3);">
Firma la carta manifiesto con tu <strong>FIEL</strong> (e.firma) para autorizar a Facturapi a timbrar CFDI ante el SAT.
Si no la ves, usa el botón para abrirla en una pestaña nueva.
</p>
<div style="border:1px solid var(--color-border); border-radius:var(--radius-md); overflow:hidden; background:var(--color-bg-base);">
<iframe
id="manifiesto-iframe"
src="https://www.facturapi.io/embedded/manifiesto"
title="Firma de Carta Manifiesto"
style="width:100%; height:720px; border:0; display:block;"
loading="lazy"
allow="fullscreen"
></iframe>
</div>
<div style="margin-top:var(--space-3); display:flex; gap:var(--space-3); justify-content:flex-end;">
<a class="btn btn--ghost btn--sm" href="https://www.facturapi.io/manifiesto" target="_blank" rel="noopener">
Abrir portal de firma
</a>
<button type="button" class="btn btn--secondary btn--sm" onclick="Invoicing.reloadManifiesto()">
Recargar firma
</button>
</div>
</div>
</div>
<!-- CONFIGURACIÓN DE SERIES — full width -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">