← 🛠️ Volver a Utilidades
Easy-IMS Gateway

What we actually run

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)
2POST /v1/ecf
{file_type, content, format_id?}
3Return #1 — sync
202 · {submission_id, status: "RECEIVED",
e_ncf, issuer_rnc, ...}
Return #2 — async, later
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
SIM — DGII Simulator
dgiiCF :8003 · embedded mock
EASYIMS_GW_DGII_MODE=SIM
REAL — DGII Web Services
ecf.dgii.gov.do · fc.dgii.gov.do
EASYIMS_GW_DGII_MODE=REAL
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 mapped 19
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 record name + RNC, created via /admin/tenants — admin-provisioned, not self-service
ONCE
An API Bearer token shown once at creation — only the hash is stored after. Identifies the tenant on every call
ONCE
A signing certificate matching their RNC on 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_id webhook 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 authorization not sent to the middleware at all — must already exist with DGII before REAL mode works for that RNC (see "Going live" below)
Every POST /v1/ecf call:
FieldExpected
Authorizationheader — Bearer <tenant token>
file_type"XML" or "JSON"
contentthe document, as a string — not a file upload (see json_mapper.py section above for how a non-canonical one gets translated)
is_signedXML only — true = relay as-is, false = middleware signs it
format_idomit → canonical/default; 0 → force canonical this once; an id → map through that JsonFormat
callback_urloptional, 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 protocol semilla → 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 certificate issued 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 ranges done in DGII's Oficina Virtual, per document type (31/32/33/34)
NEED
Pass CerteCF certification DGII's required test batch, issued and accepted across each type — a gate, not optional
NEED
Production authorization from DGII granted only after CerteCF passes
LATER / PHASE 2
PSFE authorization — only for third parties needed 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):
EndpointCalled from / whenReturns
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 exposeDoesReturns
1GET /fe/autenticacion/api/semilla hands out a one-time seed<SemillaModel>
2POST /fe/autenticacion/api/validacioncertificado verifies the signed seed + issues a Bearer token{token,expira,expedido}
3POST /fe/recepcion/api/ecf mandatory — receive an e-CF issued to us, persist it (InboundDoc) a signed ARECF (Acuse de Recibo)
4POST /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).
StepWho / whereDetail
1IssuePOST /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).
2DGII acceptsworker.py The usual sign → send → poll until ACCEPTED/CONDITIONAL.
3Deliveryreceptor_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.
4Buyer receivesPOST /fe/recepcion/api/ecf Stored as an InboundDoc (kind ECF); a signed ARECF (Estado 0 = recibido) goes back. This only says “it arrived”.
5Buyer's system lists itGET /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).
6Accept / rejectPOST /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.
7Back to the issuerInboundDoc (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.
8Issuer sees itGET /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.
People Gateway database Companies (gateway clients) Karla (an accountant) one email · one password · one MFA A company's administrator 👥 Usuarios: invites / limits in ITS company Gateway operator admin portal · 👥 Portal · any company portal_users — the person email · password hash · MFA · lockout portal_user_access — one row per company role: Administrador | Consulta RNC/Cédula limit (Consulta only) · on/off portal_sessions — in the DB, revocable works in ONE company · POST /portal/switch CESEHE Karla here: Administrador (sees every RNC) EL MUNDO DE LAS JUNTAS Karla here: Consulta · only RNC 133200155 Comprobantes (submissions) active company only ∩ allowed RNCs · never mixed Invitation and sign-in 1 · Invite👥 Portal or 👥 Usuariosknown email: company added 2 · One-time link72 h invite · 24 h resetonly its hash is stored 3 · EmailResend (HTTPS API)portal@easy-ims.com 4 · Set password≥10, letters + digitsends other sessions 5 · Sign in5 wrong = 15 min lockAdministrador ⇒ MFA (QR) 6 · Companyselector, one at a time12 h · 2 h idle EasyIMS “Portal” button — already signed in as the company EasyIMS · Portal button invoices, collections, POS, retail GET /ecf/gateway-portal-link POST /v1/auth/token company token stays on the server → 1-hour token + public_url Portal address 1º EasyIMS “URL del portal” 2º EASYIMS_GW_PUBLIC_URL · 3º gateway URL /portal#at=… fragment never reaches a log wiped on load · company only
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 order 31, 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$250k each 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 hand DGII'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:
ActionDoes
▶️ Ejecutaruploads the .xlsx, creates one CertRun + one CertRunRow per line, runs every phase in order on a background thread
🔁 Usarstarts 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
↻ Reintentarretries one REJECTED row, advancing its e-NCF by 1 first — DGII never allows resubmitting the same sequence number
🔄 Refrescarre-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
⬇️ XMLdownloads 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.