Saltar al contenido principal

SIFEN — Facturación multi-moneda

Guía técnica para emitir DTEs en moneda extranjera (USD, EUR, BRL, ARS, CLP, etc.) en OnnixConnect. Escrita tras resolver los 4 errores que aparecen en cadena al enviar una FE multi-moneda a SIFEN TEST.

Estado: 5/5 monedas aprobadas (0260) con cotizaciones reales del BCP.


Contexto

Cuando la moneda de la operación no es PYG, SIFEN exige 4 piezas adicionales en el XML — y si falta cualquiera, rechaza. Las 4 tienen que estar simultáneamente correctas (sin tolerancia a errores parciales):

  1. Descripción oficial de la moneda (D016 dDesMoneOpe) coincidiendo con el catálogo ISO 4217 del XSD Monedas_v150.xsd.
  2. Condición del tipo de cambio (D017 dCondTiCam) = 1 (global) o 2 (por ítem).
  3. Tipo de cambio por forma de pago (E611 dTiCamTiPag) dentro de cada pago del grupo gCamCond.
  4. Total general en guaraníes (F023 dTotalGs) dentro de gTotSub = dTotGralOpe × dTiCam.

Además, si dCondTiCam=1 (global) NO hay que emitir dTiCamItem (E725) ni dTotOpeGs (EA009) por ítem — ambos son exclusivos de D017=2.


Archivos involucrados

ArchivoRol
app/Domains/Sifen/Enums/Moneda.phpEnum con códigos ISO 4217 y sus descripciones oficiales SIFEN (del XSD, en inglés). Cambiar una descripción rompe 1206.
app/Domains/Sifen/Services/Xml/SifenXmlBuilder.phpprepareData() setea dCondTiCam, dTiCam, dTotalGs. prepareItem() maneja el flag emitirTotGs.
resources/views/sifen/partials/g_dat_gral_ope.blade.phpEmite <cMoneOpe>, <dDesMoneOpe>, <dCondTiCam>, <dTiCam>.
resources/views/sifen/partials/g_cam_cond.blade.phpEmite <dTiCamTiPag> dentro de <gPaConEIni> cuando moneda ≠ PYG.
resources/views/sifen/partials/g_cam_item.blade.phpEmite <dTotOpeGs> solo si emitirTotGs=true (nunca con D017=1).
resources/views/sifen/partials/g_tot_sub.blade.phpEmite <dTotalGs> al final de gTotSub cuando moneda ≠ PYG. Orden XSD crítico.
docs/xsd/Monedas_v150.xsdFuente de verdad de las descripciones oficiales ISO 4217.

Catálogo oficial SIFEN (más usadas en LATAM)

Estas son las descripciones textuales que acepta SIFEN — sensible a mayúsculas, acentos y espacios. No usar las descripciones del BCP ni traducirlas al español.

CódigoDescripción oficialNota
PYGGuaraniSin tilde
USDUS DollarNo "Dólar Americano"
EUREuro
BRLBrazilian RealNo "Real Brasileño"
ARSArgentine PesoNo "Peso Argentino"
CLPChilean PesoNo "Peso Chileno"
COPColombian Peso
UYUPeso UruguayoEn español (así figura en el XSD)
BOBBoliviano
PENNuevo SolEn español
GBPPound Sterling
JPYYenNo "Yen Japonés"
CNYYuan Renminbi
CADCanadian Dollar
AUDAustralian Dollar
CHFSwiss Franc
MXNMexican Peso
HKDHonk Kong DollarCon typo "Honk" — así está en el XSD oficial

Si necesitás una moneda fuera de esta lista: abrir docs/xsd/Monedas_v150.xsd y buscar el <CodeName> del código ISO correspondiente.


Mapa de errores SIFEN y sus fixes

Secuencia típica que aparece cuando se activa multi-moneda por primera vez:

#CódigoCampoCausaFix
11206D016 dDesMoneOpeDescripción en español en vez de la del XSDEnum Moneda::description() con nombres ISO 4217 (inglés)
21556E611 dTiCamTiPagFalta <dTiCamTiPag> dentro de <gPaConEIni>Agregar en g_cam_cond.blade.php condicional a moneda ≠ PYG
31858EA009 dTotOpeGsSe emite por ítem aunque dCondTiCam=1Quitar: solo se emite cuando D017=2 (por ítem)
42382F023 dTotalGsFalta el total general en Gs en gTotSubAgregar <dTotalGs> = dTotGralOpe × dTiCam al final de gTotSub

Errores secundarios a tener en cuenta:

CódigoCampoObservación
0160"XML malformado [dXxx es invalido]" suele ser por orden incorrecto de elementos en gTotSub. Verificar orden XSD.
1207D017Condición del tipo de cambio no informada cuando moneda ≠ PYG
1208D017aCondición del tipo de cambio informada cuando moneda = PYG
1209D018dTiCam no informado cuando dCondTiCam=1
1555E610Descripción moneda por tipo de pago no coincide con código (mismo catálogo)
1557E611adTiCamTiPag informado cuando moneda del pago = PYG

Detalle técnico de cada fix

Fix 1 — Enum Moneda con descripciones del XSD

app/Domains/Sifen/Enums/Moneda.php:

public function description(): string
{
return match ($this) {
self::PYG => 'Guarani',
self::USD => 'US Dollar',
self::EUR => 'Euro',
self::BRL => 'Brazilian Real', // ← antes: 'Real Brasileño' (1206)
self::ARS => 'Argentine Peso', // ← antes: 'Peso Argentino' (1206)
self::CLP => 'Chilean Peso', // ← antes: 'Peso Chileno' (1206)
// ... resto del match
};
}

Fix 2 — dTiCamTiPag en gPaConEIni

resources/views/sifen/partials/g_cam_cond.blade.php:

<gPaConEIni>
<iTiPago>1</iTiPago>
<dDesTiPag>Efectivo</dDesTiPag>
<dMonTiPag>{{ $dMonTiPag }}</dMonTiPag>
<cMoneTiPag>{{ $cMoneOpe }}</cMoneTiPag>
<dDMoneTiPag>{{ $dDMoneTiPag }}</dDMoneTiPag>
@if(!empty($dTiCam) && $cMoneOpe !== 'PYG')
<dTiCamTiPag>{{ $dTiCam }}</dTiCamTiPag>
@endif
</gPaConEIni>

Fix 3 — No emitir dTotOpeGs por ítem cuando D017=1

Manual v150 línea 3803 (E725): "Obligatorio si D017 = 2. No informar si D017 = 1". Manual v150 línea 3887 (EA009): "Obligatorio si existe el campo E725".

Como OnnixConnect siempre usa dCondTiCam=1 (global), ni dTiCamItem ni dTotOpeGs deben ir en cada ítem. Solo dTotalGs al final.

app/Domains/Sifen/Services/Xml/SifenXmlBuilder.php en prepareItem():

// dTotOpeGs solo se emite si dCondTiCam=2 (por ítem). Como usamos
// global (=1), no debe emitirse — Manual v150 EA009 (No informar si D017=1).
'emitirTotGs' => false,

Fix 4 — dTotalGs en gTotSub con orden correcto

app/Domains/Sifen/Services/Xml/SifenXmlBuilder.php en prepareData():

'dTotalGs' => $moneda !== 'PYG'
? number_format(round($dTotGralOpe * $tipoCambio), 0, '.', '')
: null,

resources/views/sifen/partials/g_tot_sub.blade.phpdespués del bloque IVA:

<dTotGralOpe>{{ $dTotGralOpe }}</dTotGralOpe>
@if(!$esAFE)
<dIVA5>{{ $dIVA5 }}</dIVA5>
<dIVA10>{{ $dIVA10 }}</dIVA10>
... (resto campos IVA)
<dTBasGraIVA>{{ $dTBasGraIVA }}</dTBasGraIVA>
@endif
@if(!empty($dTotalGs))
<dTotalGs>{{ $dTotalGs }}</dTotalGs>
@endif

Orden XSD completo de gTotSub (respetar exactamente): dSubExe → dSubExo → dSub5 → dSub10 → dTotOpe → dTotDesc → dTotDescGlotem → dTotAntItem → dTotAnt → dPorcDescTotal → dDescTotal → dAnticipo → dRedon → dComi → dTotGralOpe → dIVA5 → dIVA10 → dLiqTotIVA5 → dLiqTotIVA10 → dIVAComi → dTotIVA → dBaseGrav5 → dBaseGrav10 → dTBasGraIVA → dTotalGs.

Poner dTotalGs fuera de ese orden produce 0160 — XML malformado [dXxx es invalido].


Resultado final

5 FE de prueba enviadas a SIFEN TEST el 2026-04-20 con cotizaciones del BCP:

MonedaTC Gs/METotal METotal GsDTE idCDC
USD6.379,25500,003.189.6252790260
EUR7.230,00400,002.892.0002800260
BRL1.278,532.000,002.557.0602840260
ARS4,7050.000,00235.0002820260
CLP7,80300.000,002.340.0002830260

Cómo probar multi-moneda end-to-end

Script de prueba

php artisan tinker --execute="require base_path('scripts/tinker_test_enviar_multimoneda.php');"

Envía 1 FE en cada una de las 5 monedas (USD/EUR/BRL/ARS/CLP) con cotizaciones del BCP y muestra resumen.

Verificación del XML generado

# Inspeccionar los campos relevantes del XML firmado
php artisan tinker --execute='
$dte = \App\Domains\Sifen\Models\SifenDte::where("moneda","!=","PYG")->latest()->first();
$xml = file_get_contents(\Storage::disk("local")->path($dte->xml_signed_path));
foreach (["cMoneOpe","dDesMoneOpe","dCondTiCam","dTiCam","dTiCamTiPag","dTotOpeGs","dTotalGs"] as $tag) {
if (preg_match("/<{$tag}>(.+?)<\/{$tag}>/", $xml, $m)) echo "$tag = {$m[1]}\n";
}
'

Salida esperada (ejemplo USD total 500):

cMoneOpe = USD
dDesMoneOpe = US Dollar
dCondTiCam = 1
dTiCam = 6379.2500
dTiCamTiPag = 6379.2500
dTotalGs = 3189625

Nota: dTotOpeGs (por ítem) NO debe aparecer — si aparece, revisar emitirTotGs en prepareItem().


Notas para debug

  • No usar cotizaciones del BCP como referencia de descripciones. El BCP usa descripciones en español ("REAL BRASILEÑO", "PESO ARGENTINO") que no pasan la validación 1206. La fuente de verdad es docs/xsd/Monedas_v150.xsd.
  • Preferir dCondTiCam=1 (global). Un solo tipo de cambio para todo el DTE. Más simple y menos campos que calcular. Usar D017=2 solo si hay razones de negocio (ej. facturas con ítems adquiridos en distintos momentos del día con tipos de cambio distintos).
  • Redondeo: el tipo de cambio usa 4 decimales fijos (number_format($tc, 4, '.', '')). El total en guaraníes se redondea a entero (round($total * $tc)).
  • Orden XSD: validar contra docs/xsd/DE_v150.xsd cuando aparece 0160 — XML malformado.

Referencias

  • docs/xsd/Monedas_v150.xsd — catálogo oficial ISO 4217 con descripciones exactas.
  • docs/manuales-md/Manual_SIFEN_v150_completo.md:
    • Líneas 2850-2880 — campos D015, D016, D017, D018 (grupo D1 moneda y tipo de cambio).
    • Líneas 3800-3900 — campos E721, E725 (precio ítem, tipo de cambio ítem) y EA008, EA009 (total ítem, total ítem en Gs).
    • Líneas 4685-4700 — campo F023 dTotalGs (total general en guaraníes).
    • Líneas 7100-7130 — validaciones 1204-1209 (IVA, moneda, tipo de cambio).
    • Líneas 7510-7530 — validaciones 1554-1557 (moneda y tipo de cambio por forma de pago).
    • Líneas 7660-7720 — validaciones 1853-1862 (cálculos de ítem).
    • Líneas 8140-8170 — validaciones 2382-2386 (total general en Gs).