Same job as the gateway/event-bus/broker diagram, one process doing all of it.
Solid teal = a real module or table in app/.
Dashed amber = a box that diagram has and this system doesn't.
real component
not implemented
ERP tenant
1builds JSON — its own canonical shape, or a third party's own shape (see below)
GET /v1/ecf/{id} or webhook → final dgii_status + track_id
app.main:app — one process, one Render service
HTTP layer
ecf.py · admin.py portal.py · admin_portal.py
API Gateway
tenants call the app directly
auth.py
token hash lookup admin-key guard
Tenant · JsonFormat
tables, not a service
🤝 this is the middleman
json_mapper.py
translates a THIRD PARTY's own JSON into your canonical shape — skipped entirely on your own submissions
Rate limiting
none, anywhere
submissions table
RECEIVED → SENT → EN_PROCESO → done
⏰ auto-woken per submission
worker_loop
picks up RECEIVED — separate Render service,
sleeps if idle. wake_worker.py
pings it on every new POST /v1/ecf, retrying up to 5x —
Render's own edge answers a still-cold service with 429,
which isn't a real wake and must not be treated as one
(confirmed live 2026-09-10: it was, silently, for a while)
reconcile_loop
re-polls until terminal
webhook_loop
delivers callback_url
ecf_xml.py
signer.py
runtime toggle · SIM ⇄ REAL
dgii_client.py · dgii_real.py
picked per-call by EASYIMS_GW_DGII_MODE (flip live from the admin portal)
Event bus
direct calls instead
SigningAudit
one row per sign/send
semilla → sign → token, then signed XML → POST /recepcion/api/facturaselectronicas
First — immediate
recepción returns trackId
just an ack — not the verdict yet
Then — later, polled
GET /consultaresultado/api/consultas/estado?trackid=… → dgii_status: ACCEPTED / CONDITIONAL / REJECTED
One deployable, easy to operate. The trade-off: no per-component scaling, no true async decoupling,
and the four dashed boxes above are the ones actually missing if we outgrow this shape.
Steps ①②③ and the two returns are written out in full in Call_DGII_Return.md.
Inside the middleman — json_mapper.py
The actual field-by-field connection, using the real AdingSoft mapping already
registered and tested. Each numbered pair is one line in the stored
JsonFormat.mapping spec.
Their JSON (AdingSoft, real example)
1Rnc
2company_name
3company_address
4customer_rnc
5customer_name
6ncf
7date_joined
8ncfDateDue
9terms"Crédito"/"Contado"
10subtotal
11Tax
12total
13invoicedetail[].product
14invoicedetail[].description
15invoicedetail[].qty
16invoicedetail[].price
17invoicedetail[].subtotal
18invoicedetail[].taxType
Canonical JSON (yours)
emisor.rnc1
emisor.razon_social2
emisor.direccion3
comprador.rnc4
comprador.razon_social5
e_ncf6
fecha_emision7
fecha_vencimiento_secuencia8
tipo_pago9
auto-derived from #6's own prefix, not mapped19
totals.subtotal10
totals.total_tax11
totals.total12
lineas[].item_sku13
lineas[].nombre_item14
lineas[].cantidad15
lineas[].precio_unitario16
lineas[].monto_item17
lineas[].indicador18
#9 also passes through a map: {"Crédito": "2", "Contado": "1"}.
#19 (tipo_ecf) has no left-side source at all — json_mapper.py
reads it straight out of #6's own value once resolved, since every DGII eNCF encodes its type in characters 2–3.
ITBIS en la línea (2026-10): the canonical payload carries indicador_monto_gravado
— "0" line amounts without ITBIS, "1"monto_item includes it. It's the issuing company's setting (EasyIMS: Configuración de
Empresa → ITBIS en la línea); ecf_xml.py writes it as
IndicadorMontoGravado for types 31/32/33/34/41/45. Bases and totals are always without ITBIS.
Accepted by DGII (TesteCF, 2026-10-09).
What we expect from a tenant
Split the same way as everywhere else on this page: what's provisioned
once by an admin, vs. what's expected on
every call.
ONCE
A Tenant recordname + RNC, created via /admin/tenants — admin-provisioned, not self-service
ONCE
An API Bearer tokenshown once at creation — only the hash is stored after. Identifies the tenant on every call
ONCE
A signing certificate matching their RNCon the Tenant directly, or centrally in SigningCert (one cédula cert can cover several RNCs). Missing this fails every submission at signing time — the most common real-world gap
ONCE, optional
default_callback_url / default_format_idwebhook push instead of polling; and/or always map this tenant's own submissions through a registered JsonFormat without saying so every time
ONCE, DGII-side
e-CF emisor registration, e-NCF ranges, CerteCF, Production + PSFE authorizationnot sent to the middleware at all — must already exist with DGII before REAL mode works for that RNC (see "Going live" below)
EveryPOST /v1/ecf call:
Field
Expected
Authorization
header — Bearer <tenant token>
file_type
"XML" or "JSON"
content
the document, as a string — not a file upload (see json_mapper.py section above for how a non-canonical one gets translated)
is_signed
XML only — true = relay as-is, false = middleware signs it
format_id
omit → canonical/default; 0 → force canonical this once; an id → map through that JsonFormat
callback_url
optional, overrides the tenant's default for this call only
For canonical JSON, only emisor.rnc is actually validated up front — everything else in the canonical shape just needs to be there for ecf_xml.build_xml() to use.
Try it by hand: 🧪 the test client.
Going live with real DGII
What changes at n-dgiiclient in the diagram above —
one config switch in code, several things that aren't code at all.
dgii_client.py
SIM — talks to the local simulator (dgiiCF :8003 or the embedded mock)
⇄
runtime switch — flip live, no restart
dgii_real.py
REAL · TESTECF | CERTECF | PRODUCTION toggled from the admin portal (/admin/dgii-mode), persisted across restarts
HAVE
dgii_real.py already implements the real protocolsemilla → sign → Bearer token → recepción → consulta resultado, plus the simplified recepción FC path — but unverified against a real DGII environment
NEED
A real DGII-authorized certificateissued by an authorized CA (Avansi, Cámara de Comercio, …) — for the RNC you'll test with
NEED
Registration as e-CF emisor + authorized e-NCF rangesdone in DGII's Oficina Virtual, per document type (31/32/33/34)
NEED
Pass CerteCF certificationDGII's required test batch, issued and accepted across each type — a gate, not optional
NEED
Production authorization from DGIIgranted only after CerteCF passes
LATER / PHASE 2
PSFE authorization — only for third partiesneeded only when transmitting on behalf of other companies. Signing your OWN RNCs (e.g. CESEHE + your other company, both under your cédula cert) does not require it — that's being your own provider
The 5 real DGII endpoints, in the order dgii_real.py actually calls them. Paths are verbatim from DGII's "Servicios DGII" spec; {amb} = testecf | certecf | ecf, base ecf.dgii.gov.do (FC on fc.dgii.gov.do):
Endpoint
Called from / when
Returns
1
GET {amb}/autenticacion/api/autenticacion/semilla
_auth_token() — top of every send()/get_status(), but only hits the network when there's no cached token for that RNC (token TTL from DGII's expira)
<SemillaModel> XML (valor + fecha)
2
POST {amb}/autenticacion/api/autenticacion/validarsemilla
_auth_token(), right after #1 — the seed gets signed with the RNC's own cert, posted as multipart xml
{"token","expira","expedido"}
3
POST {amb}/recepcion/api/facturaselectronicas
send() — once per submission, the instant worker_loop picks up a RECEIVED row. Not used for #4's case
{trackId,error,mensaje}
4
POST fc.dgii.gov.do/{amb}/recepcionfc/api/recepcion/ecf
send(), instead of #3 — only when tipo_ecf=32 (Consumo) and total < RD$250,000. Different host (fc.dgii.gov.do). What's actually POSTed is a separately built and signed "Resumen de Factura de Consumo" (RFCE) — never the full invoice — carrying the Código de Seguridad from the full invoice's own SignatureValue (ecf_xml.build_rfce() + security_code(); confirmed 2026-07-30 against DGII's own written procedure)
estado + mensajes[] — already final, no trackId, nothing to poll. A 400 with a well-formed body (estado/mensajes present) is a normal terminal rejection — e.g. a genuine duplicate e-NCF — _post_with_retry() only retries a truly transient/garbled 400 (fixed 2026-07-30; used to discard the real DGII response and crash the whole batch instead)
5
GET {amb}/consultaresultado/api/consultas/estado?trackid=…
get_status(), called by reconcile_loop repeatedly for anything still SENT/EN_PROCESO, using the trackId #3 returned
estado + mensajes[] — this is where the real verdict (ACCEPTED/CONDITIONAL/REJECTED) comes from
Per document: #1–2 happen roughly once an hour per RNC (token reuse); #3 or #4 happens exactly once; #5 happens N times — unless #4 was used, in which case #5 never runs at all.
Receptor role — the /fe/* endpoints we expose
The other half of certification (flow steps 7–11): as an electronic receiver,
DGII and other issuers call us. These are public (app/routers/fe.py),
no tenant token — the caller authenticates via the semilla→token flow below.
Their URLs are what you register at step 7 (Prueba) and step 12 (Producción).
Must be served over public HTTPS.
Endpoint we expose
Does
Returns
1
GET /fe/autenticacion/api/semilla
hands out a one-time seed
<SemillaModel>
2
POST /fe/autenticacion/api/validacioncertificado
verifies the signed seed + issues a Bearer token
{token,expira,expedido}
3
POST /fe/recepcion/api/ecf
mandatory — receive an e-CF issued to us, persist it (InboundDoc)
a signed ARECF (Acuse de Recibo)
4
POST /fe/aprobacioncomercial/api/ecf
mandatory — receive an ACECF (buyer's commercial approval)
HTTP 200 / 400
Inbound-seed signature is always integrity-checked; when DGII-authorized CA roots are placed in app/trust/ the signer cert must also chain to one of them (set EASYIMS_GW_RECEPTOR_REQUIRE_CA=1 to fail closed when none are configured). The ARECF is signed with our certificate for the RNCComprador.
Received e-CFs, commercial approval & delivery — the full loop
Built 2026-10-09/10. What happens after DGII accepts an e-CF whose buyer is another company: the e-CF is
delivered to the buyer, the buyer accepts or rejects the operation (ACECF), and that decision travels back to DGII
and to the issuer. Each tenant only ever sees its own side: e-CFs issued to its RNC, and approvals about
e-CFs it issued. Example from the live test: ELMUNDO (133200155) → CESEHE (132307739).
Step
Who / where
Detail
1
Issue
POST /v1/ecf
The issuer's system sends the e-CF with entregar_receptor: true — EasyIMS does it automatically
whenever the buyer has an RNC/cédula (never for consumidor final, AP bills, exports).
2
DGII accepts
worker.py
The usual sign → send → poll until ACCEPTED/CONDITIONAL.
3
Delivery
receptor_delivery.py
Claims accepted submissions with deliver_to_receptor = true, looks the buyer up in
DGII's directory of electronic receptors (cached), authenticates with the buyer's own semilla→token and posts
the signed XML to its urlRecepcion. Not in the directory → not delivered, nothing fails.
4
Buyer receives
POST /fe/recepcion/api/ecf
Stored as an InboundDoc (kind ECF); a signed ARECF (Estado 0 = recibido) goes back. This only says “it arrived”.
5
Buyer's system lists it
GET /v1/inbound?since_id
EasyIMS → e-CF Recibidos: issuer, e-NCF, amounts parsed from the XML, “Proveedor nuevo” + “Crear proveedor”.
Also /v1/inbound/{id}/xml, /print (Representación Impresa),
/consulta (DGII ConsultaTimbre link).
6
Accept / reject
POST /v1/inbound/{id}/approval
Builds the ACECF (Estado 1 aceptado / 2 rechazado + DetalleMotivoRechazo ≤250), signs it with the buyer's
certificate and sends it to DGII (aprobacioncomercial). One decision per e-CF.
Bulk accept/reject in EasyIMS = this call per e-CF.
7
Back to the issuer
InboundDoc (kind ACECF)
Issuer is a tenant of this same gateway → the ACECF is recorded for it directly. Otherwise it's posted to the
issuer's urlAceptacion from DGII's directory (delivery mode permitting); external issuers' ACECFs
arrive at POST /fe/aprobacioncomercial/api/ecf.
8
Issuer sees it
GET /v1/inbound/approvals
EasyIMS stamps the invoice: “✓ Comprador aceptó” / “✕ Comprador rechazó — motivo”, plus a Comprador filter.
Delivery rules: delivered only when the issuer asks (entregar_receptor=true)
and DGII's directory lists the buyer. The old per-tenant “Entrega al receptor” switch is no longer consulted. The global
mode in the admin portal is now the 🛑 Emergencia brake (keep LIVE; OFF stops every delivery). Motivation: e-CF becomes
mandatory for every taxpayer from 16-11-2026, so every buyer with an RNC will be an electronic receptor.
Company portal from EasyIMS, already signed in: EasyIMS trades the company's token server-side for a 1-hour access token
(POST /v1/auth/token) and opens /portal#at=…;
the fragment never reaches a server log and the portal wipes it on load. Every “Portal” button in EasyIMS uses it, so switching
companies never needs a token copied — and never leaves another company's session in the portal.
Client portal — who sees what
Built 2026-10-13. Companies that only use the gateway see their comprobantes at
https://portal.easy-ims.com/portal. Each person has one login
(email, password, MFA); their access is per company, with its own role and RNC/Cédula limit; a session works in
one company at a time. Rules: portal_auth.py, routes:
portal.py / admin.py.
Isolation: every request takes the company from the session (PortalSession.tenant_id)
and the role / RNC limit from that company's access row; tenant_rncs() narrows to the limit, and
a limited person never sees rows without an issuer RNC. A company administrator never sees a person's other companies, and may not reset
the password or MFA of someone who also has another company — the new-password link would take over the other access too; only the
gateway operator can. Each company keeps at least one active administrator; removing a person's last company deletes the person.
Settings:EASYIMS_GW_PUBLIC_URL (links and EasyIMS's Portal button),
EASYIMS_GW_RESEND_API_KEY + optional EASYIMS_GW_MAIL_FROM
(emails; without a key the link is only shown on screen). The portal list never downloads XML bodies: buyer fields are extracted inside
Postgres and auto-refresh asks only for what changed (updated_since) — after the Neon
data-transfer quota incident of 2026-10-13.
Certification runner — a second, separate path to DGII
certrunner.py (wrapped by
/admin/certrun and the admin portal's
"🧪 Certificación automática" card) does not go through Tenant auth, the
submissions table, or
worker_loop/reconcile_loop/webhook_loop at all — it's a separate orchestration reusing only
the low-level building blocks above (ecf_xml.py,
signer.py,
dgii_real.py) to drive DGII's one-time
CerteCF certification (steps 4–6 of the 15-step Flujo Certificación) from an uploaded Set de
Pruebas .xlsx. It exists so certifying a
new RNC never has to touch, or risk, the production Submission pipeline.
PHASE 1
All base types, in DGII's mandated order31, 32≥RD$250k, 41, 43, 44, 45, 46, 47 — every one Aceptado before phase 2 starts
PHASE 2
Notes — 33 (Débito), 34 (Crédito)only after ALL of phase 1 is Aceptado; each references an e-NCF issued in phase 1
PHASE 3
RFCE — Resúmenes de Consumo < RD$250keach built + signed separately from its full invoice via ecf_xml.build_rfce() — see endpoint #4 above
PHASE 4 · MANUAL
Upload the full Consumo<250k invoices, by handDGII's certification portal, once phase 3's resúmenes are Aceptado — no API for this step, not automatable
CertRun / CertRunRow — persisted so the browser can poll live progress across a run of ~25 rows:
Action
Does
▶️ Ejecutar
uploads the .xlsx, creates one CertRun + one CertRunRow per line, runs every phase in order on a background thread
🔁 Usar
starts a new run reusing a past run's already-saved .xlsx + RNC + Modo + Ambiente — no re-upload; the file was kept on disk since the original run specifically for this
↻ Reintentar
retries one REJECTED row, advancing its e-NCF by 1 first — DGII never allows resubmitting the same sequence number
🔄 Refrescar
re-queries DGII's live ConsultaResultado for that row's stored Track ID — catches DGII's backend silently invalidating an already-Aceptado result after the fact
⬇️ XML
downloads the EXACT signed XML transmitted for that row (persisted at send-time, not rebuilt on demand — a rebuild would carry a different FechaHoraFirma/digest than what actually went out)
Same dgii_real.py client as the Submission pipeline above — SIM/REAL and TESTECF/CERTECF/PRODUCTION are picked per-run, not globally, so a certification run in CERTECF doesn't disturb concurrent production traffic already in REAL/PRODUCTION.