Live Testing Guide
All live tests are SKIPPED by default in CI and in any offline run. They are opt-in:
only when the channel-specific flag is set to 1 AND the required credential env vars
are present does the suite actually run — otherwise describe.skip fires silently.
The shared gate helper is at:
backend/src/modules/documents/transports/live-gate.ts
Hard-success contract (enforced per-spec):
- A
REJECTEDorSKIPPEDtransmission result fails the test (not tolerates it). - An empty or missing authority reference/id fails the test.
- Async portals must reach
CLEAREDwithin the specified poll window.
Channel summary
Status legend — three different claims, kept visually distinct:
- ✅ Proven live (dated) — a real round-trip against the real authority/network/sandbox, actually run and verified by reading the platform's own response, not merely a green mocked test.
- 🟡 Implemented, awaiting credentials/accreditation — the code exists and is ready to run, but the full round-trip has never actually happened in this architecture (missing token, certificate, or account). Where a narrower credential-free reachability check has been proven live, that is named explicitly — it proves the host/path answer for real, never that the full flow would succeed.
- 🔴 Deferred / not implemented — no working code path for the live leg yet, or the round-trip requires an account this project does not have and has not attempted to obtain.
| Channel | Flag | Key creds | Spec file | Status |
|---|---|---|---|---|
| KSeF (PL) | KSEF_LIVE=1 | KSEF_AUTH_TOKEN, KSEF_NIP | ksef/ksef.live.spec.ts | 🟡 Credentials present, round-trip unverified — KSEF_AUTH_TOKEN/KSEF_NIP do exist as CI secrets today (confirmed by name via gh secret list, not by value). The same secrets authenticated successfully against ksef-test.mf.gov.pl as recently as 2026-07-14 (a CI run of the pre-refactor compliance/providers/transmission/ksef/ksef-live.spec.ts: real submission, a semantic REJECTED — code 450 — not an auth failure). No live run has exercised the current, post-refactor spec, and compliance-live.yml has not been triggered since the 2026-08-29 engine refactor — so whether the same credentials are still valid today is unverified, not proven expired. |
| PDP superpdp (FR) | PDP_LIVE=1 | PDP_BASE_URL, PDP_CLIENT_ID, PDP_CLIENT_SECRET | pdp/pdp.live.spec.ts | ✅ Round-trip proven — fr:200 → fr:201 → fr:202, deposit 375037, 2026-08-29 |
| PDP reception (FR) — inbound e-invoices | PDP_LIVE=1 | PDP_BASE_URL, PDP_CLIENT_ID, PDP_CLIENT_SECRET (same credentials as the row above — one PDP account only, see below) | pdp/pdp-reception.live.spec.ts | ✅ Listing + download proven live 2026-09-16 — a self-addressed deposit's own INBOUND twin (direction=in, a DIFFERENT id from the outbound one) was listed, downloaded (real %PDF- bytes), extracted, and turned into a real received-invoice DocumentInstance by the REAL PdpReceptionSweepRunner; then "approve" and a full "record-payment" ran for real too. 🟡/🔴 the buyer-side lifecycle PUSH (pushLifecycleStatus) is proven NOT reachable on this sandbox — every code tried (fr:203/fr:205/fr:206/fr:211/fr:212) and every plausible path variant answered a generic 404 — see the dedicated section below |
| Email (document "send" SMTP delivery) | DOCUMENTS_MAIL_LIVE=1 | (none — hits the local Mailpit container the dev/test stack already runs, SMTP :1025 / API :8025; needs DATABASE_URL for one throwaway Company row) | actions/send-quote.live.spec.ts | ✅ Proven live (2026-08-31) — a real message read back from Mailpit's own API, with the PDF attachment actually present and the subject genuinely interpolated |
| Iopole (FR) | IOPOLE_LIVE=1 | PDP_IOPOLE_CLIENT_ID, PDP_IOPOLE_CLIENT_SECRET, PDP_IOPOLE_CUSTOMER_ID (PDP_IOPOLE_API_BASE / PDP_IOPOLE_TOKEN_URL optional - the hosts are fixed constants in iopole-transport.ts#IOPOLE_URLS) | iopole/iopole.live.spec.ts | ✅ Round-trip proven 2026-09-24 - real Factur-X deposit accepted (201 {"type":"INVOICE","id":"01a0d29b-37d0-750d-9f8f-5d35664fca90"}) and a real positive verdict read back from the platform: SUBMITTED (destType PPF and OPERATOR) → RECEIVED (networkCode 202) → ISSUED, no rejection. Reproduced on a second, independent deposit before the assertion was tightened. See the dedicated section below. |
| SdI (IT) | SDI_LIVE=1 | SDI_ID_TRASMITTENTE, SDI_ENDPOINT, SDI_CERTIFICATE, SDI_CERT_PASSWORD | sdi/sdicoop.live.spec.ts | 🔴 Deferred (AdE accreditation) — code implemented-awaiting-accreditation, never yet run |
| SdI via PEC (IT) | PEC_LIVE=1 | PEC_ID_TRASMITTENTE, PEC_ADDRESS, PEC_SMTP_HOST, PEC_SMTP_PORT, PEC_IMAP_HOST, PEC_IMAP_PORT, PEC_USERNAME, PEC_PASSWORD | transports/sdi-pec/pec.live.spec.ts | 🟡 Implemented, awaiting credentials — no PEC mailbox exists in this checkout, and unlike SdICoop this channel needs NO accreditation at all (see credentials-guide.md §4bis and pec-protocol.ts's own header for the primary-source citations) — provisioning any PEC mailbox is the only blocker to a real round-trip |
| Chorus Pro (FR B2G) | CHORUSPRO_LIVE=1 | CHORUSPRO_CLIENT_ID, CHORUSPRO_CLIENT_SECRET, CHORUSPRO_TECH_LOGIN, CHORUSPRO_TECH_PASSWORD | chorus-pro/choruspro.live.spec.ts | ✅ Full qualification round-trip proven live 2026-09-14 — a real Factur-X deposit reached the terminal authority state IN_INTEGRE (CPP0011117000000000425903, listeErreurDP: []), after two earlier deposits were rejected and fixed (see credentials-guide.md §3 and commits 67a94d58/7de5a90c/ecce4d35). Proven in qualification only — no production PISTE application or Chorus Pro production raccordement exists, and nothing after IN_INTEGRE (a public buyer's own MISE_A_DISPOSITION/MANDATEE/MISE_EN_PAIEMENT) has been exercised. |
| Invopop (GOBL pivot) | INVOPOP_LIVE=1 | INVOPOP_API_KEY, INVOPOP_WORKFLOW_ID (INVOPOP_BASE_URL optional, normally unset) | invopop/invopop.live.spec.ts | ✅ Round-trip proven live 2026-09-24 - a real GOBL bill/invoice deposited as silo entry 01a0d28d-5c3e-7af6-8d08-f2c412a9ac28 in the invoicerr sandbox workspace, workflow job 01a0d28d-5c3e-77a1-8874-9112b9fa3e85 completed status: OK with NO faults, the envelope came back signed: true with a real ES256 signature, and the platform's own recomputed payable (290.00 EUR) matched this product's own totals exactly. Proven in sandbox only - production needs a paid Developer tier and a live workspace. NOT proven: any country-specific transmission step (the workflow used here signs and stops), and no conformity poller exists for this channel. |
| A-Cube (also a Peppol access point) | ACUBE_LIVE=1 | PDP_ACUBE_EMAIL, PDP_ACUBE_PASSWORD (PDP_ACUBE_ENVIRONMENT optional, defaults to the sandbox) | acube/acube.live.spec.ts | ✅ Round-trip proven live 2026-09-24 - a real FatturaPA, built by this repository's own fatturapa-provider.ts and gated by the real vendored Schema_VFPR12.xsd, deposited through POST /invoices on it-sandbox.api.acubeapi.com and answered 202 {"uuid": "01a0d284-f0ac-76fb-af3d-0b359d20ad2a"}; the uuid was then read back off the platform (GET /invoices/{uuid} → marking: "waiting", transmission_format: "FPR12", document_type: "TD01", the deposited payload echoed back with our own invoice number in it). Sandbox only: A-Cube does not forward a sandbox deposit to SdI at all, so no cleared/rejected SdI verdict is reachable here and none is asserted. Production terms are not published - nothing about the production host has been attempted. |
| Billit (BE, Peppol access point) | BILLIT_LIVE=1 | BILLIT_API_BASE, BILLIT_API_KEY, BILLIT_PARTY_ID (BILLIT_RECEIVER_ENDPOINT optional) | billit/billit.live.spec.ts | ✅ Round-trip proven live 2026-09-24 - a real Peppol BIS Billing 3.0 UBL, built by the real peppol-bis-provider.ts and gated by the real EN 16931 + Peppol Schematron rulesets, deposited through POST /peppol/sendxml and accepted: InboxItemID 1117029, reproduced on a second independent run as 1117033. Delivery is not inferred from the 200: the receiver ANSWERED, and GET /peppol/inbox carries one IMR per deposit from 0208:0563846944 back to 9957:FR54982187676. The invoice is a mandated French one dated the day it ran, so this is also the only place GH-448 / PR #452 (the Peppol cbc:ProfileID fix) is proven against a real platform rather than only against the vendored Schematron. The one thing that cost a day is written up in the dedicated section below: the supplier in the document must BE the Billit company. ⚠️ The sandbox is a 14-day trial opened 2026-09-24: this stops being runnable on 2026-10-08. |
| RFC 3161 TSA (-T signing) | TSA_LIVE=1 | TSA_URL | signing/tsa.live.spec.ts | ✅ Proven live — a real TST DER from FreeTSA (https://freetsa.org/tsr) embedded as a genuine XAdES-T SignatureTimeStamp; no credential needed (FreeTSA is public/anonymous). First proven 2026-06-30; re-run 2026-09-14 — TSA_LIVE=1 TSA_URL=https://freetsa.org/tsr npx jest tsa.live --no-coverage --runInBand → 3/3, exit 0 (HttpTsaClient, XadesSigningProvider level-T, the env-built signing registry) |
| Company lookup (national registers) | COMPANY_LOOKUP_LIVE=1 | (none — every source is keyless: 15 national registers + VIES + GLEIF + Peppol Directory) | modules/company-lookup/company-lookup.live.spec.ts | ✅ Proven live (2026-07-27) |
| Company lookup, through the onboarding wizard UI (same provider chain, driven by Cypress rather than calling the service directly) | COMPANY_LOOKUP_LIVE=1 (passed as --env COMPANY_LOOKUP_LIVE=1 to Cypress — note Cypress delivers it as a NUMBER, so the spec compares with String(...), not ===) | (none, same reason as above) | e2e/cypress/e2e/18-onboarding-wizard.cy.ts (one it inside a shared describe, not a separate file — its title itself states the gate) | ✅ Proven live (created 2026-08-30; re-run 2026-09-14) — 4/4 passing with the gate open, EDF's real SIRET pre-filling the form and the persisted company read back from the database. Not run by any CI workflow (neither cypress.yml's default Tests job nor a Cypress equivalent of compliance-live.yml, which does not exist) — offline, this test shows as Cypress "Pending", by design, same as the row above. |
Local OCR engine (the ocr-image repo, our own ocrmypdf-based image) ⚙ not a channel — the only OCR engine this product talks to; the backend calls it directly (OCR_SERVICE_URL), no intermediate role/process and no cloud engine of any kind | LOCAL_OCR_LIVE=1 | (none — no cloud key, that is the entire point; the spec docker pulls + runs the published image (ghcr.io/invoicerr-app/ocr-image) via docker, gated on a usable local Docker daemon — docker info — checked at load time) | ocr-service/local-client.live.spec.ts | ✅ Round-trip proven on 2026-09-11 (engine switched from apache/tika:latest-full to our own image, same day) — the spec pulls and launches the real container, POSTs a real pdf-lib-built invoice PDF to it, and the heuristic mapping correctly reads HT/TVA/TTC and the VAT id back; because this server force-OCRs every page (see server.py's own header), this jest run now exercises REAL Tesseract recognition automatically, unlike the Tika era which needed a separate manual proof for that. A SEPARATE, MANUAL round-trip the same day against genuinely RASTERIZED (image-only) invoice PDFs — one French, one Polish (the new language pack Tika's own stock image never had) — proved the broader language coverage too; see local-client.ts's own header for that citation |
| Stripe (online payment) | STRIPE_LIVE=1 | STRIPE_SECRET_KEY | payments/providers/stripe/stripe.live.spec.ts | 🔴 Deferred — no Stripe test-mode account provisioned for this task; this row and workflow wiring were themselves missing until the Mollie/PayPal work (2026-09-15) added them retroactively for consistency. Get a key: dashboard.stripe.com → Developers → API keys → "Secret key" (test mode), sk_test_.... |
| Mollie (online payment) | MOLLIE_LIVE=1 | MOLLIE_API_KEY | payments/providers/mollie/mollie.live.spec.ts | 🔴 Deferred — no Mollie account provisioned for this task (2026-09-15, "aucune clé sandbox n'est encore disponible" — see this feature's own PR). Get a key: my.mollie.com → Developers → API keys → the "Test" key, test_.... |
| PayPal (online payment) | PAYPAL_LIVE=1 | PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET (PAYPAL_WEBHOOK_ID, PAYPAL_ENVIRONMENT optional for this narrower spec — see its own header) | payments/providers/paypal/paypal.live.spec.ts | 🔴 Deferred — no PayPal sandbox app provisioned for this task (2026-09-15). Get credentials: developer.paypal.com → Apps & Credentials → Sandbox → create/open an app → Client ID + Secret. |
Polar (hosted billing, WARNING__ENABLE_BILLING_FOR_USERS__WARNING) — product listing only | POLAR_LIVE=1 | POLAR_ACCESS_TOKEN (sandbox organization access token — POLAR_ORGANIZATION_ID optional, only narrows the listing) | billing/polar.live.spec.ts | ✅ Proven live — lists the organization's own products (polarClient.products.list), confirming the token/organization are real. |
Polar, option A (one Polar customer PER COMPANY, hosted-billing.md) — customer/checkout/portal/webhook-resolution round-trip | POLAR_LIVE=1 | POLAR_ACCESS_TOKEN, POLAR_PRODUCT_ID_MONTHLY | billing/billing-option-a.live.spec.ts | ✅ Proven live 2026-09-16 — through this repo's own checkout-session.ts/billing-customer.ts/portal-session.ts/webhook-handlers.ts (never a hand-rolled duplicate of the SDK calls), for a fresh sandbox Company row: a real company-scoped Polar customer (external_id = company.id), a real checkout session, a real portal session, and the webhook-resolution logic run against that same real customer id, read back correctly from the DEV Postgres database. Also driven through the real UI (Settings > Billing, logged in as the e2e seed OWNER) against a locally running test-flag-enabled stack: billing-email save, "Subscribe monthly" (real redirect to sandbox.polar.sh/checkout/...), and "Manage subscription" (real redirect to sandbox.polar.sh/.../portal/overview) all confirmed live. Does NOT prove an actual Polar-delivered HTTP webhook reaching POST /api/billing/webhooks/polar (no reachable public endpoint here) or a completed sandbox payment (no such API exists — see the live spec's own header). |
✅ Round-trip proven on 2026-08-29 — after two false-greens fixed the same day
The result, verified by querying the platform rather than trusting the spec:
api:uploaded → fr:200 Déposée (validée) → fr:201 Émise par la plateforme → fr:202 Reçue par la plateforme. Deposit 375037 for the invoice, 375061 for the credit note. The French conformity check passes for both.The credit note needed two more fixes, each one named by the platform itself:
BR-FR-CO-05/BT-3— «Si le type de facture est un avoir […] au moins une référence à une facture antérieure (BT-25) avec sa date (BT-26) doit être présente au niveau entête. Références entête trouvées : 0.» ("If the invoice type is a credit note […] at least one reference to a prior invoice (BT-25) with its date (BT-26) must be present at header level. Header references found: 0.") The link had always existed in the database (Invoice.correctsInvoiceId); nothing carried it into the document.Element 'qdt:DateTimeString': This element is not expected— the namespace normalizer was DROPPING thexmlns:qdtdeclaration without ever rewriting the elements, which then went out with an undeclared prefix. Invisible as long as no document used it: BT-26 was the first.What was missing: the three mandatory mentions of C. com. art. L441-9 I al. 5. Once added, the
BR-FR-05rejection disappeared — replaced by a purely structural defect the platform named for us: «Element 'ram:Content' must occur exactly 1 times». The generator was stacking threeram:Contentelements inside a singleIncludedNote, which is invalid in CII. Fixed in post-processing, which now splits one note per mention and recovers BT-21 from the#CODE#prefix.TWO false-greens, not one. The first: the spec asserted
PENDINGright after the deposit, before the verdict even existed — asserting a transient state is asserting that the request went out, not that it succeeded. The second, deeper one:poll()could never return anything other thanPENDING. It readinvoice.status_code, a field the API does not return; the lifecycle arrives inevents[]instead. The poll therefore answered "no status codes" on every call, and had from the start. And the mapping collapsedfr:200,fr:201andfr:202ontoPENDING, conflating "not yet judged" with "validated and received by the recipient".The spec now fails if the document stays
PENDING: a transient state is no longer a success.Two sandbox constraints, verified the same day. superpdp refuses any deposit whose BT-2 is later than the current day — but that does NOT prevent testing: just date the invoice today. And the sandbox already contains Burger Queen (
000000002) and Tricatel (000000001).Historical note — the intermediate diagnosis, kept for the record
The transport works: OAuth, XSD, namespaces, routing, and superpdp accepts the deposit. Then it rejects the document. Verified by querying the platform,
GET /v1.beta/invoices/374891:event
fr:213 Rejetée— «BR-FR-05/BT-22 : La mention relative aux frais de recouvrement (code PMT) est absente. Elle est obligatoire dans les notes (BG-1).» ("The mention relating to recovery costs (PMT code) is missing. It is mandatory in the notes (BG-1).")Same for PMD (late-payment penalties) and AAB (early-payment discount). Invoicerr emits none of the three: every French invoice it produces is rejected by the conformity check. This is a product gap, not a credentials problem — and the wording of these mentions comes from the seller's own commercial terms, so it cannot be invented.
Why nobody caught it, and this is the transposable lesson: the spec asserted
PENDINGright after the deposit and polled only once, before the verdict even existed.PENDINGis a real state, but a transient one — asserting a transient state is asserting that the request went out, not that it succeeded. The spec had also, separately, stopped compiling (a fixture missingvatCategory, which became mandatory once BT-151 moved under the engine's own resolution): it threw before ever reaching the network, and the live gate stayed silent.Two sandbox constraints, verified the same day. superpdp refuses any deposit whose BT-2 is later than the current day, so shifting the clock is useless: France only routes to a PDP starting 2026-09-01, and the two windows only overlap on that exact day. And the sandbox already contains Burger Queen (
000000002) and Tricatel (000000001) — using a different SIREN means creating the company on superpdp's side first.
PDP reception (FR) — inbound e-invoices, 2026-09-16
France's e-invoicing reform requires every company in scope to be able to RECEIVE structured e-invoices through an accredited platform from 2026-09-01 (CGI art. 289 bis, I — see
received-invoice.descriptor.ts's own "receive" country-policy note for the raw-text citation). This session wired the READ side of the existing "pdp" channel:transports/pdp/pdp-reception.ts(buyer-side lifecycle codes + the non-fatal status pusher),conformity/pollers/ pdp-reception-poller.ts(list + download + extract, reusing the SAME structural extraction the manual upload screen already uses),conformity/reception-sweep-runner.ts(a BullMQ repeatable,PdpReceptionSweepRunner, mirroringConformitySweepRunner's own "pure core / thin persistence shell" split), and two new received-invoice actions (rejectnow takes a REQUIREDreason;record-paymentmirrors the invoice's own, minus cross-currency conversion).Only ONE PDP account is available to this session —
.env.test.localcarries a singleclient_id/client_secretpair, not a separate seller+buyer pair (credentials-guide.md's own PDP section). A real cross-company B2B reception therefore could not be tested; a SELF-ADDRESSED deposit was used instead — this company (GET /v1.beta/companies/meresolves to "Burger Queen", VATFR18000000002, SIREN-ish000000002, routing315143296_1422) depositing an invoice to its OWN identifiers. Proven live, in one continuous run (pdp/pdp-reception.live.spec.ts):
POST /v1.beta/invoices(multipart Factur-X) — a real deposit, e.g. id604994.GET /v1.beta/invoices?direction=in&limit=…— lists the deposit's own INBOUND TWIN, a DIFFERENT id (e.g.604994out → its own separate "in" record). Confirms the platform models sent and received as two distinct records even for a self-addressed deposit.GET /v1.beta/invoices/{id}?format=original— the correct download endpoint (an EARLIER version of this code guessed a/filesub-path that does not exist — a real, live 404 — before testing revealed this is the SAME endpointgetInvoice()already calls, just answered withcontent-type: application/pdfand real bytes instead of JSON).PdpClient.downloadInvoiceFile()reads this response asarrayBuffer(), neverres.text()(which would UTF-8-corrupt the binary).- The downloaded bytes ran through the EXACT SAME
received-invoices/extraction.tsthe manual upload dialog uses, and the result (supplier,currency,grossAmount, …) became a realreceived-invoiceDocumentInstanceviaDocumentsService.runAction('receive')— the same entry point the HTTP controller uses, never a shortcut.approvethen a fullrecord-payment(900 units against a 900-unitgrossAmount) both ran for real too, ending "fully settled".The buyer-side lifecycle PUSH is a documented, LIVE-VERIFIED gap, not a guess. The XP Z12-012 naming convention this codebase's own
pdp-client.tsalready documented (fr:203"prise en charge",fr:205"accepted by buyer",fr:211/fr:212"payment sent/received") suggested aPOST /v1.beta/invoices/{id}/lifecycle_eventspush — this session tried it, live, against BOTH the outbound id and its inbound twin, and against every plausible path variant (lifecycle-events,/events,/status,/statuses,/lifecycle, a plainPUTon the invoice itself): every one answered the identical, generic404 {"http_status_code":404}— the same shape a genuinely unregistered route returns (compare a real validation failure, which answers 400/422 with a specific message). Conclusion: superpdp's free sandbox ("API Flux") does not expose ANY lifecycle-status-push route today, under any name this codebase or the XP Z12-012 convention suggested. The code (pushLifecycleStatus, andpdp-reception.ts's ownpushTakenInCharge/pushApproved/pushRejected/pushPaid) is kept, not deleted: a real PA (Plateforme Agréée, production) may implement it, and every call already degrades to a LOGGED, NON-FATAL no-op on this exact failure — the local status change is always persisted first and is never rolled back or blocked by a platform that cannot (yet) accept the push. What remains unverified: the EXACT numeric code for "refused by buyer" (no source found names it —fr:206is this codebase's own best-effort guess, following the observed numbering pattern, never presented as researched fact — seepdp-reception.ts's own header) and whether a REAL PA implements this endpoint at all.The buyer-side statuses THEMSELVES are real, not invented: DGFiP's own "Facturation électronique : guide pratique de démarrage au 1er septembre 2026" (
impots.gouv.fr/sites/default/files/media/1_metier/2_professionnel/EV/2_gestion/ 290_facturation_electronique/guide_pratique_facturation_electronique.pdf, read as raw PDF text, Q12 "Que faire si l'acheteur refuse ma facture ?") confirms a buyer's "refusée" status is a genuine, distinct "statut de cycle de vie", "obligatoirement motivé et ne peut être utilisé que pour les motifs prévus par la norme" — grounding this task's decision to makereject's ownreasonparameter REQUIRED, not merely offered.E2E coverage (
e2e/cypress/e2e/74-received-invoice-inbound.cy.ts) proves the SCREEN side offline: a real localnode:httpserver shaped like the three endpoints above (cypress.config.ts'sstartFakePdpServer) stands in for superpdp, and the REALPdpReceptionSweepRunneris triggered on demand as a REAL BullMQ job on the backend's owndocument-actionqueue (triggerPdpReceptionSweep, using thebullmqpackage directly — never a stub of the sweep runner itself) rather than waiting on its own 5-minute default interval.
Iopole (FR) - a second French transmission platform, 2026-09-24
Iopole (
iopole.com) is registered by the DGFiP, French, and exposes a REST API that accepts UBL, CII and Factur-X - the three shapes the documents module already produces. It is wired as an ordinary transport (transports/iopole-transport.ts+transports/iopole/iopole-client.ts), registered under the idiopole, opted into throughCompany.invoiceTransportIdlike every other one. Sandbox:api.ppd.iopole.fr. Production:api.iopole.com.The credentials live under the
PDP_IOPOLE_prefix (three required, two optional):
Variable What it is Where a company reads its own PDP_IOPOLE_CLIENT_IDThe account's e-mail address. Unusual for OAuth2, and correct: the delivered token carries it back as its own client_idclaim, withpreferred_username: service-account-<that e-mail>. Do not "fix" it.the Iopole account itself PDP_IOPOLE_CLIENT_SECRETthe OAuth2 client_credentialssecretIopole console PDP_IOPOLE_CUSTOMER_IDsent as the customer-idHTTP header on every call, not only at authenticationGET /v1/config/customer/idPDP_IOPOLE_API_BASEoptional override of the fixed API host - PDP_IOPOLE_TOKEN_URLoptional override of the fixed token endpoint - Three things the platform documentation does not say, or says wrong - each verified live, each recorded in
iopole/iopole-client.ts's own header so nobody re-discovers them:
- The
client_idis an e-mail address (above).- The token lasts 1740 seconds, not the 3600 the documentation claims. The client derives its expiry from the response alone and never from a compiled-in default; a response with no usable
expires_inexpires the token immediately rather than assuming an hour. A hardcoded 3600 would hand out a dead token for eleven minutes, and the symptom (an intermittent 401 on an unrelated call) names nothing about the cause.customer-idis required on every call. The platform's own OpenAPI document marks the headerrequired: falseon every operation, which is wrong in practice for an operator account. It is not the sandbox scope that appears in the token'sscopeclaim (iopole_<8 chars>); the two look alike and swapping them fails late, on a call that will not say why.A fourth gap was found by running the round trip rather than by reading anything:
GET /v1/invoice/{id}answers with an array of one metadata object where the OpenAPI document declares a single object. The client accepts both shapes.Source of truth for the endpoints: the platform's own OpenAPI document, fetched from the SANDBOX host (
GET https://api.ppd.iopole.fr/v1/api/operator/invoicing), not the prose documentation - which is a client-rendered page that serves no content to a plain HTTP client.The two parties in the live spec are not invented. They are two of the four business entities this project's sandbox account actually has registered on the
DOMESTIC_FRnetwork, read fromGET /v1/config/business/entity: AIGLE TRANSPORT (SIREN789275732) and BARKOCZY (SIREN841480502), whose directory addresses are0225:789275732and0225:841480502. Neither carries an explicitPEPPOL_ENDPOINTidentifier, deliberately: with a FrenchLEGAL_IDon file,build-semantic-invoice.ts#endpointForalready derives exactly0225:<SIREN>, which is what the sandbox directory registered. The VAT numbers are computed from those SIRENs, not made up.What is proven: a real Factur-X PDF/A-3, built by the production recipe and gated by the real vendored EN 16931 Schematron, deposited through
POST /v1/invoice, accepted with a real invoice id, read back throughGET /v1/invoice/{id}, and followed to a genuine positive verdict in the platform's own status history. The verdict landed in well under a second on every deposit measured, and the spec polls for it rather than sleeping - a loop that stopped at "the history is no longer empty" read onlySUBMITTED, which says the request went out and nothing about whether it was accepted. That is the first of the two false greens the superpdp box above records, reproduced here verbatim before it was fixed.What is NOT proven, and is not claimed:
- Nothing in production. Only the
ppdsandbox has ever been reached.- No conformity poller. Following the verdict as a background job belongs in
conformity/pollers/and is separate work;GET /v1/invoice/{id}/status-historyis the endpoint it would be built on, and only the live spec reads it today. The transport therefore setsproviderId: 'iopole'for the record's own honesty, and the sweep never selects it - exactly the positionsdiholds.- No reception (inbound) side, unlike the
pdpchannel.- No webhook, and no participant registration. This project's sandbox account holds the
userrole, which per Iopole's own documentation allows sending an invoice, sending a status and reading the directory, but not declaring a webhook or adding a participant. Neither was attempted and neither is worked around.- Nothing ran through
iopole-transport.ts#send()itself. The live spec is DB-free by design (the same choicepdp.live.spec.tsmakes) and composes the same DB-free building blocks by hand; the orchestration around them is covered byiopole-transport.spec.ts, with mocks.
Running a single live spec
# KSeF (PL) — KSEF_AUTH_TOKEN/KSEF_NIP DO exist as CI secrets (see the summary table above); whether
# they are still valid today has not been re-verified since 2026-07-14
KSEF_LIVE=1 KSEF_AUTH_TOKEN=<token> [KSEF_NIP=<nip>] \
npx vitest run ksef.live --no-file-parallelism
# PDP superpdp (FR) — round-trip proven: deposited, validated, issued, received (see the box above)
set -a; . .env.pdp.local; set +a
PDP_LIVE=1 npx vitest run pdp.live --no-file-parallelism
# Iopole (FR) - round-trip proven 2026-09-24: Factur-X deposited, accepted, and followed to a real
# positive verdict (SUBMITTED -> RECEIVED/202 -> ISSUED). See the dedicated section above.
# DB-FREE - no DATABASE_URL needed. PDP_IOPOLE_API_BASE/PDP_IOPOLE_TOKEN_URL are optional: the hosts
# are fixed constants in iopole-transport.ts#IOPOLE_URLS.
set -a; . /path/to/pdp-sandbox.env; set +a
IOPOLE_LIVE=1 npx vitest run iopole.live --no-file-parallelism
# PDP reception (FR) — inbound e-invoices: self-addressed deposit -> direction=in -> download ->
# extract -> real received-invoice -> approve -> record-payment (see the dedicated section above).
# DB-CONNECTED (like pdp-conformity.live.spec.ts) — reads backend/.env's own DATABASE_URL, creates
# and cleans up one throwaway Company.
set -a; . .env.test.local; set +a
PDP_LIVE=1 npx vitest run pdp-reception.live --no-file-parallelism
# Email (document "send" SMTP delivery to the local Mailpit container — no external creds needed,
# but needs Mailpit running on :1025/:8025 and a DATABASE_URL for one throwaway Company row)
DOCUMENTS_MAIL_LIVE=1 SMTP_HOST=localhost SMTP_PORT=1025 \
DATABASE_URL=postgresql://invoicerr:invoicerr@localhost:5433/invoicerr_db \
npx vitest run send-quote.live
# Billit (BE, Peppol access point) - Peppol BIS UBL deposit. The credentials live outside this
# repository; put them in a gitignored .env.billit.local (see "Credential storage" below).
set -a; . .env.billit.local; set +a
BILLIT_LIVE=1 npx vitest run billit.live --no-file-parallelism
# SdI (IT) — requires AdE accreditation + qualified PFX certificate (code implemented-awaiting-accreditation)
SDI_LIVE=1 SDI_ID_TRASMITTENTE=IT01234567890 SDI_ENDPOINT=<accredited-SdIRiceviFile-url> \
SDI_CERTIFICATE=<base64-pfx> SDI_CERT_PASSWORD=<pass> \
npx vitest run sdicoop.live --no-file-parallelism
# SdI via PEC (IT) — NO accreditation needed, only a real PEC mailbox (code implemented-awaiting-credentials)
PEC_LIVE=1 PEC_ID_TRASMITTENTE=IT01234567890 PEC_ADDRESS=fatture@example.pec.it \
PEC_SMTP_HOST=smtps.pec-provider.it PEC_SMTP_PORT=465 \
PEC_IMAP_HOST=imaps.pec-provider.it PEC_IMAP_PORT=993 \
PEC_USERNAME=fatture@example.pec.it PEC_PASSWORD=<pass> \
npx vitest run pec.live --no-file-parallelism
# Chorus Pro (FR B2G) — full qualification round-trip proven live 2026-09-14 (deposit +
# terminal IN_INTEGRE); production never attempted. Omitting the TECH_LOGIN/PASSWORD pair
# still runs the spec, but only its OAuth half — see credentials-guide.md §3.
CHORUSPRO_LIVE=1 CHORUSPRO_CLIENT_ID=<id> CHORUSPRO_CLIENT_SECRET=<secret> \
CHORUSPRO_TECH_LOGIN=<login> CHORUSPRO_TECH_PASSWORD=<password> \
npx vitest run choruspro.live --no-file-parallelism
# A-Cube (also a Peppol access point) - round-trip proven 2026-09-24 (deposit accepted, uuid
# read back off the platform). DB-FREE: this spec never sets DATABASE_URL and never touches Prisma.
# QUOTE THE PASSWORD IN SINGLE QUOTES - an A-Cube password legitimately contains `#`, and an
# unquoted shell assignment silently truncates the value there, which then looks like an
# authentication bug rather than a shell bug.
ACUBE_LIVE=1 PDP_ACUBE_EMAIL=<account e-mail> PDP_ACUBE_PASSWORD='<account password>' \
npx vitest run acube.live --no-file-parallelism
# RFC 3161 TSA — level-T signing via real TSA (e.g. FreeTSA)
TSA_LIVE=1 TSA_URL=https://freetsa.org/tsr \
npx vitest run tsa.live --no-file-parallelism
# Local OCR engine (the ocr-image repo, our own ocrmypdf-based image — the only OCR engine this
# product talks to, no cloud key at all). Requires a usable local Docker daemon (`docker info`) —
# the spec pulls, runs, uses, and tears down the published container itself; nothing needs to be
# started manually first (the first run pulls ghcr.io/invoicerr-app/ocr-image; every run after
# reuses Docker's layer cache).
LOCAL_OCR_LIVE=1 npx vitest run local-client.live
# Stripe (online payment) — test-mode secret key from the Stripe dashboard
STRIPE_LIVE=1 STRIPE_SECRET_KEY=sk_test_... \
npx vitest run stripe.live --no-file-parallelism
# Mollie (online payment) — test API key from the Mollie dashboard
MOLLIE_LIVE=1 MOLLIE_API_KEY=test_... \
npx vitest run mollie.live --no-file-parallelism
# PayPal (online payment) — sandbox app Client ID/Secret from the PayPal developer dashboard.
# PAYPAL_WEBHOOK_ID/PAYPAL_ENVIRONMENT are read but not required by this narrower spec (order
# creation only, no webhook verification round-trip — see the spec's own header).
PAYPAL_LIVE=1 PAYPAL_CLIENT_ID=<id> PAYPAL_CLIENT_SECRET=<secret> \
npx vitest run paypal.live --no-file-parallelism
# PAYMENT_PROVIDERS_REAL=1 wires the REAL Stripe/Mollie/PayPal clients into the RUNNING app itself
# (e.g. `npm run start:test`) instead of only inside a `*.live.spec.ts` process — the two code paths
# `documents-core.module.ts#shouldUseRealPaymentClients`'s own header explains. No effect outside
# NODE_ENV=test.
PAYMENT_PROVIDERS_REAL=1 npm run start:test
# A FULL Mollie round-trip (unlike stripe.live.spec.ts/paypal.live.spec.ts, which only prove session/
# order creation) needs Mollie's own servers to actually deliver a webhook back to this machine — set
# BACKEND_PUBLIC_URL to a tunnel exposing only :4000 (`APP_URL` itself must stay the frontend's local
# origin, see that variable's own header in `utils/backend-public-url.ts`) before starting the backend.
PAYMENT_PROVIDERS_REAL=1 BACKEND_PUBLIC_URL=https://your-tunnel.example.com npm run start:test
Verifying the gate works (no flag = skipped)
# Run the gated spec without the flag → must show as skipped
cd backend
npx vitest run ksef.live
# Expected: Test Files 1 skipped | Tests 1 skipped
npx vitest run pdp.live pdp-reception.live send-quote.live sdicoop.live tsa.live choruspro.live
# Expected: all suites skipped
Running the full offline suite (gate must not fire)
cd backend
npx vitest run
# Live specs appear in the "skipped" count — no live call is made.
# Baseline (2026-09-13, under the since-replaced Jest runner): 2740 passed / 58 skipped, 254 of 278
# suites run — this number drifts as the codebase grows and predates the Jest→Vitest migration
# (2026-09-19); treat it as a sanity check of the shape (most suites run, live ones skip), not a
# pinned target or a Vitest-reporter-accurate count.
CI
Live specs are excluded from CI by default. The CI workflow (Tests, backend-tests job) runs:
cd backend && npx vitest run --maxWorkers=2
No *_LIVE=1 flag is set in CI. All gated suites remain skipped.
Credential storage
- Credentials are loaded exclusively from env vars or
.env*.localfiles (gitignored). - Never commit secrets to the repository.
- Suggested local file layout:
.env.ksef.local—KSEF_AUTH_TOKEN,KSEF_NIP.env.pdp.local—PDP_BASE_URL,PDP_CLIENT_ID,PDP_CLIENT_SECRET.env.billit.local-BILLIT_API_BASE,BILLIT_API_KEY,BILLIT_PARTY_ID.env.sdi.local—SDI_ID_TRASMITTENTE,SDI_ENDPOINT,SDI_CERTIFICATE,SDI_CERT_PASSWORD.env.invopop.local-INVOPOP_API_KEY,INVOPOP_WORKFLOW_ID.env.acube.local-PDP_ACUBE_EMAIL,PDP_ACUBE_PASSWORD,PDP_ACUBE_ENVIRONMENT
- Load with:
set -a; . .env.<channel>.local; set +a - A-Cube is the one exception to that load command. Its password legitimately contains
#, andset -a; . filehands the line to the shell, which treats everything from an unquoted#onward as a comment - the variable is then silently set to a TRUNCATED value and every login fails with a message that says nothing about truncation. Either single-quote the value inside the file (PDP_ACUBE_PASSWORD='...'), or read the file rather than source it.
A-Cube (also a Peppol access point) - sandbox account
A-Cube gives a sandbox away with no purchase obligation: a work e-mail at acubeapi.com/sandbox,
an activation link, a password. Nothing to sign. Production terms are not published - worth
asking before anything depends on it.
ACUBE_LIVE=1 \
PDP_ACUBE_EMAIL=<account e-mail> \
PDP_ACUBE_PASSWORD='<account password>' \
[PDP_ACUBE_ENVIRONMENT=sandbox] \
npx vitest run acube.live --no-file-parallelism
| Env var | Purpose |
|---|---|
PDP_ACUBE_EMAIL | The A-Cube account's own e-mail - required by the gate |
PDP_ACUBE_PASSWORD | The A-Cube account's own password - required by the gate. Single-quote it, see above |
PDP_ACUBE_ENVIRONMENT | sandbox (default) or production - anything other than production is read as the sandbox |
Authentication is a password exchange, not an API key. POST https://common.api.acubeapi.com/login
with a JSON body of email, password and environment answers a 24-hour RS256 JWT, presented as
Authorization: Bearer <token> afterwards. There is no per-integration key concept: the credentials
that open the API are the credentials that open the web console. Two consequences worth stating
plainly - use a dedicated, generated password that serves nothing else, and treat this value in a
company's channel config as account-wide rather than scope-limited (the settings copy in
acube-transport.ts says so to the user).
The environment is a HOST, not a request parameter (verified live, 2026-09-24, both halves): a
sandbox token against https://it-sandbox.api.acubeapi.com/invoices answers 200, and the SAME
token against https://it.api.acubeapi.com/invoices answers
401 {"code":401,"message":"Invalid JWT Token"}. Mixing the two is a hard refusal, never a silent
cross-environment send.
Only the Italian jurisdiction is implemented on the wire, because FatturaPA is the payload this
repository already builds and already gates against the real Agenzia delle Entrate XSD. The same
login already returns roles for fr., de., pl. and peppol.api.acubeapi.com (read out of the
real token), so widening it is a row in acube-client.ts's own JURISDICTION_HOSTS plus a format
decision - not a second authentication design. Nothing Peppol is built today.
Italy is nonetheless OUT OF SCOPE for this transport, by decision. Italian sending stays on
sdi-pec, which is sourced and where this product receives the notifiche itself. An Italian company selectingacubeis refused at send time bychannel-policy/data/it.json(itssdimandate lists onlysdi-pecas equivalent) - that refusal is intended, not a defect to fix. The round-trip above proves the wire works, nothing more.What the legal research established, in both directions: Italian law does allow a third party to transmit on the seller's behalf (Provvedimento Agenzia delle Entrate 30 aprile 2018, prot. n. 89757, punto 5.1) and such an intermediary needs no professional registration. A-Cube claims on its commercial pages to be "accreditati con SdI", but nothing public confirms it: the Agenzia delle Entrate publishes no register, and accreditation is a bilateral Accordo di Servizio. The one objective clue points at reception only - their documentation asks you to register their codice destinatario, and per the AdE's own process a codice destinatario is issued only for a channel accredited in reception; accreditation in transmission is a separate box, and nothing public says they hold it.
Chorus Pro (FR B2G) — PISTE gateway
# Chorus Pro PISTE sandbox
CHORUSPRO_LIVE=1 \
CHORUSPRO_CLIENT_ID=<piste_client_id> \
CHORUSPRO_CLIENT_SECRET=<piste_client_secret> \
CHORUSPRO_TECH_LOGIN=<compte_technique_login> \
CHORUSPRO_TECH_PASSWORD=<compte_technique_password> \
[CHORUSPRO_ENVIRONMENT=SANDBOX] \
npx vitest run choruspro.live --no-file-parallelism
| Env var | Purpose |
|---|---|
CHORUSPRO_CLIENT_ID | PISTE OAuth2 client_id (from PISTE developer portal) — required by the gate |
CHORUSPRO_CLIENT_SECRET | PISTE OAuth2 client_secret — required by the gate |
CHORUSPRO_TECH_LOGIN | Chorus Pro "compte technique" login (optional — the OAuth half runs without it; the deposit half is skipped when absent) |
CHORUSPRO_TECH_PASSWORD | Chorus Pro "compte technique" password (same optionality as above) |
CHORUSPRO_ENVIRONMENT | SANDBOX (default) or PROD |
How to obtain credentials: see credentials-guide.md §3 for the full step-by-step (both layers
are obtainable with no real company — the Chorus Pro qualification space issues a fictitious
SIRET). In short:
- Create an account on piste.gouv.fr, subscribe to the Chorus Pro APIs (Factures/Structures/Utilisateurs/Transverses) in the sandbox catalog, and copy the OAuth Credentials tab's Client ID/Secret Key.
- Create a "matelas de données" on the Chorus Pro qualification portal
(
chorus-pro.gouv.fr/qualif/), then a "compte technique" from it — this is whereCHORUSPRO_TECH_LOGIN/CHORUSPRO_TECH_PASSWORDcome from. - Sandbox hosts:
sandbox-oauth.piste.gouv.fr/sandbox-api.piste.gouv.fr.
What the test verifies:
- Step 1: OAuth2
client_credentials→ Bearer token reachable — proven live 2026-09-14. - Step 2 (if compte technique provided):
POST /cpro/factures/v1/deposer/flux→ realnumeroFluxDepotreturned — proven live 2026-09-14 (CPP0011117000000000425903). - Step 3:
POST /cpro/transverses/v1/consulterCRDetaille(NOT/cpro/factures/v1/consulter/cr— that route does not exist, seechoruspro-client.ts's own header) →etatCourantDepotFluxreached the terminal stateIN_INTEGREwithlisteErreurDP: []— proven live 2026-09-14, in qualification. Nothing past that terminal state (a real public buyer's own processing) has been exercised, and no production round-trip exists.
Invopop (GOBL pivot) - sandbox, proven 2026-09-24
# Invopop sandbox workspace
INVOPOP_LIVE=1 \
INVOPOP_API_KEY=<workspace_api_key> \
INVOPOP_WORKFLOW_ID=<published_workflow_uuid> \
npx vitest run src/modules/documents/transports/invopop/invopop.live.spec.ts
| Env var | Purpose |
|---|---|
INVOPOP_API_KEY | The workspace API key (a JWT). Required by the gate. It carries the workspace with it, so it is also what decides sandbox or live. |
INVOPOP_WORKFLOW_ID | The UUID of a published workflow for schema bill/invoice in that same workspace. Required by the gate: a silo entry with no workflow to run has been transmitted nowhere. |
INVOPOP_BASE_URL | Optional. Defaults to https://api.invopop.com, which serves every workspace. |
How to obtain credentials:
- Sign up at invopop.com and open the Console. A sandbox workspace is self-serve, with no sales call and no company registration.
- Console → Configuration → API Keys → create a key. It is scoped to that one workspace.
- Console → Workflows → Load template (or
PUT /transform/v1/workflows/{uuid}with a workflow JSON) for schemabill/invoice, publish it, and copy its id. The workflow used for the proven round trip below is a singlesilo.close("Sign envelope") step, which is the smallest thing that proves the platform accepted, built, enveloped and signed the document. - Check which workspace the key belongs to before depositing anything:
GET /access/v1/workspaceanswers"sandbox": trueorfalse.
Three things that will waste an afternoon if nobody wrote them down:
- There is no separate sandbox host.
https://api.invopop.comserves every workspace and the TOKEN decides which one. The spec refuses to run against a workspace whosesandboxflag is not true; that check is the only guard there is. - A
403from this API is not an authentication failure. The edge sits behind Cloudflare, which answers403with a body oferror code: 1010to a client whose signature it does not like, valid token or not. A genuinely bad or missing token answers401with{"message":"missing authorization token"}. This is whyinvopop-client.tssends an explicitUser-Agenton every request and whyInvopopApiErrornames a 403 as a BLOCKED CLIENT. Do not remove that header as noise. - The token does not expire (
scope: admin,exp: 0). There is no refresh cycle to build.
What the test verifies (the hard-success contract for this channel):
GET /utils/v1/pinganswerspong, andGET /access/v1/workspaceanswerssandbox: true.PUT /silo/v1/entries/{uuid}stores a real GOBLbill/invoiceand returns a non-empty entry id. An empty id is a FAILURE, never a silent success.PUT /transform/v1/jobs/{uuid}?wait=30runs the published workflow. A job carryingfaultsis a FAILURE even when itsstatusreadsOK: the platform's own documentation states that a job whose step failed and whose error branch then ran reports exactly that combination. Reading success offstatusalone would be a false green of the kind this guide exists to prevent.GET /silo/v1/entries/{uuid}comes backsigned: trueafter the signing step.- The platform's recomputed
totals.payableequals this product's owngrossMinor. GOBL recalculates every total and, by its own documentation, silently replaces any supplied one with no error, so the returned number is the only signal there is that the two engines agree.
What the round trip did NOT prove: any country-specific conversion or transmission step (the
workflow used signs and stops, and no government app was enabled on the sandbox workspace); a
production deposit (that needs a paid Developer tier and a live workspace); and any post-deposit
conformity tracking, since no poller is registered for the invopop provider id in
conformity/pollers/. That poller is named, separate work, the same remainder the PDP channel
carried before its own was built.
Billit (BE, Peppol access point) prerequisites
Billit is a Belgian Peppol access point, also registered by the DGFiP as a French "plateforme
agreee". This transport speaks Peppol only - it deposits a Peppol BIS Billing 3.0 UBL document
through POST /peppol/sendxml and treats the InboxItemID that comes back as the delivery
reference. Billit's French PA flow is a different set of endpoints and is deliberately out of scope.
Credentials
Two headers, on every call, named exactly as Billit's own examples write them (https://docs.billit.be/docs/partyid-and-key):
| Env var | Header | What it is |
|---|---|---|
BILLIT_API_KEY | apikey | The account's secret key. One key covers every company on the account. |
BILLIT_PARTY_ID | partyID | Which company of that account the call acts for. |
BILLIT_API_BASE | (none) | https://api.sandbox.billit.be/v1 or https://api.billit.be/v1. |
The PartyID is configuration, never a constant. The same company has a different PartyID on
sandbox and on production, and an account holding several companies has one PartyID per company.
billit-transport.ts refuses to send when it is missing rather than defaulting one, because a
defaulted PartyID would file an invoice under the wrong company without saying so.
A sandbox account is free and needs no card: my.sandbox.billit.be/Account/Register. Get a sandbox
account here before anything else - calls to the production API send real invoices.
BILLIT_RECEIVER_ENDPOINT is optional and is not a credential: it is the public Peppol
participant identifier the live spec deposits to, defaulting to 0208:0563846944, a participant
verified registered on the Peppol TEST network for BISv3Invoice on 2026-09-24. Override it if your
own sandbox has a better receiver.
The company you send as, and why the first attempts failed
The supplier carried IN the document must BE the Billit company the partyID header names.
Billit resolves the sender that way and refuses anything it cannot match, with a generic HTTP 400
whose body names nothing:
{"errors":[{"Code":"GenericError","Description":"Processing of the HTTP request resulted in an
exception. Please see the HTTP response returned by the 'Response' property of this exception
for details."}]}
That message is useless, and it is the same one you get for several unrelated causes. When you
hit it, do not guess: POST a throwaway order and read its message log. POST /orders then
POST /orders/commands/send with Transporttype: "Peppol" both answer HTTP 200 regardless, but
GET /orders/{id} afterwards carries a Messages[] entry with the real sentence in it. That is how
the two causes below were identified, an hour apart:
| What the order message log said | What it meant |
|---|---|
"The VAT Number of your company () cannot be used to send via Peppol, change your VAT number in your company record" | The company record had no VAT number. The empty () is Billit interpolating the missing value. Note it is the party's top-level VATNumber field that matters, not Identifiers, which stays [] even for a company that has one. |
| (no message - the send succeeded) | Once VATNumber was set, /orders delivered, and /peppol/sendxml still failed until the UBL's own supplier was changed to that same identity. |
So the sequence to get a new account sending is: set the company's VAT number (and, for a French company, its Siren - https://docs.billit.be/docs/create-account-and-french-integration), then put that identity in the document you deposit. Their France setup adds two further steps (the "Electronic invoicing in France" integration tile, which generates the CTC, and an automatic Peppol registration the next day at 10:00) - not needed for the Peppol test network, where the round trip below worked without them, but needed for the French annuaire in production.
Round-trip proven live on 2026-09-24
Run through the real peppol-bis-provider.ts, the real EN 16931 UBL Schematron and the real
vendored Peppol delta, nothing mocked:
Billit participant lookup: { identifier: '0208:0563846944', registered: true,
documentTypes: [ 'BISv3Invoice', 'IMR', 'MLR', 'BISv3CreditNote', ... ] }
Peppol BIS UBL built and validated, bytes: 4002
Billit /peppol/sendxml response: 1117029
DEPOSIT ACCEPTED - InboxItemID: 1117029 receiver: 0208 0563846944
Reproduced on a second independent run: InboxItemID 1117033.
Note the response body is a bare integer, not a JSON object - billit-client.ts#extractInboxItemId
accepts both shapes for exactly this reason.
Delivery is not inferred from the HTTP 200. The receiver answered. GET /peppol/inbox carries
one IMR (Peppol invoice message response) per deposit:
{"InboxItems":[
{"InboxItemID":1117009,"SenderPeppolID":"0208:0563846944","PeppolDocumentType":"IMR",
"ReceiverPeppolID":"9957:FR54982187676","ReceiverCompanyID":"FR54982187676",
"CreationDate":"2026-09-24T13:51:36.48533","PeppolFileID":"0a47bff7-3730-4cb3-b019-01a0d341be79"}]}
9957:FR54982187676 is this sandbox company. The document reached the far end of the Peppol test
network and the far end replied.
Billit also runs the Peppol rules itself before accepting anything, and unlike the generic 400 above it names the rule when it is a content problem - one entry per XML location:
[PEPPOL-COMMON-R043]-Belgian enterprise number MUST be stated in the correct format.
Thrown at XML location: Invoice.AccountingSupplierParty.Party.EndpointID
Reading every entry rather than only the first is billit-client.ts#describeFailure's job.
The path to production is not open today
Two facts, both from Billit's own documentation, both worth knowing before anyone plans on this:
- The API key this transport uses is "only allowed for non-commercial integrations" (https://docs.billit.be/docs/partyid-and-key). A commercial production integration needs OAuth credentials requested from Billit support, plus an approval step before production credentials are issued (https://docs.billit.be/docs/authentication).
- The sandbox account opened on 2026-09-24 is a 14-day trial. It expires on 2026-10-08, after which this live spec stops being runnable at all until the account is extended or replaced. If you are reading this after that date and the spec fails on authentication, that is why.
This is also where the Peppol cbc:ProfileID fix is proven live
The Billit sandbox company is French, and the live spec dates its invoice the day it runs, so
every deposit above is a mandated French invoice (the French BT-23 obligation runs from
2026-09-01, content-requirements/data/fr.json, CGI ann. II art. 242 nonies A I 8 bis).
That is the exact case GH-448 was about. formats/semantic/business-process.ts derives a French CGI
category for such an invoice, and cbc:ProfileID is where UBL carries BT-23 - but Peppol reserves
that element for its own process URNs, so the French code failed PEPPOL-EN16931-R007 outright. PR
#452 fixed it by having peppol-bis-provider.ts pass businessProcessCodeOverride with the fixed
Peppol Billing profile identifier (Peppol BIS Billing 3.0 §13.2), leaving the CII/Factur-X channel
untouched.
Measured on this repository's own output for the seller and date the live spec uses:
| Syntax | BT-23 carries | Why |
|---|---|---|
Peppol BIS UBL (cbc:ProfileID) | urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 | Peppol BIS Billing 3.0 §13.2, forced by the override from #452 |
CII / Factur-X (BusinessProcessSpecifiedDocumentContextParameter/ram:ID) | S1 | the French CGI category, unchanged - the fix is scoped per syntax, not per seller |
And a real Peppol access point accepted the first one. Before this spec ran, #452 was judged only by the vendored Schematron, which is our own copy of the rules; now the document it produces has been taken by Billit and answered by the receiver. If that fix ever regresses, this is the test that will say so against something other than ourselves.
SdI prerequisites (currently deferred — code is implemented-awaiting-accreditation)
Status (2026-09-01): step 3 below is DONE — a real SdiHttpPort (SdiCoopClient,
backend/src/modules/documents/transports/sdi/sdicoop-client.ts) exists, built from the published
SdICoop WSDL/XSD/instructions (see Credentials Guide §4's own citation
list), and sdi-transport.ts already uses it whenever a company's "sdi" channel credentials are
complete. What remains is entirely OUTSIDE this codebase's control:
- Register as an intermediary (intermediario) with AdE.
- Obtain a qualified digital certificate (PFX/P12) from an accredited CA (client cert; a distinct-key
server cert too, if the notifiche receiver is to be mTLS-authenticated server-side — see
sdi-notifiche.service.ts's own header on what that endpoint still lacks). Implement— done (SdiHttpPortfor SDICoop SOAPsdicoop-client.ts).- Declare the accredited
SDI_ENDPOINT(theSdIRiceviFileURL AdE assigns) as this company's "sdi" channel credential, alongsideSDI_ID_TRASMITTENTE/SDI_CERTIFICATE/SDI_CERT_PASSWORD. - Set
SDI_LIVE=1+ all four creds and runsdicoop.live.spec.ts— the first real run against collaudo may reveal envelope discrepancies reading the spec alone could not anticipate (see that spec's own header).
SdI via PEC prerequisites (currently deferred — code is implemented-awaiting-credentials)
Status (2026-09-13): the "sdi-pec" transport (sdi-pec-transport.ts), the receipt-handling logic
(transports/sdi-pec/pec-notifiche.service.ts) and a real IMAP adapter
(transports/sdi-pec/imapflow-pec-inbox-port.ts) all exist and are unit-tested against mocked ports —
see Credentials Guide §4bis for the full citation list this was built from.
Unlike SdI's SDICoop channel, NOTHING here needs AdE accreditation. What remains:
- Provision any PEC (Posta Elettronica Certificata) mailbox from an AgID-listed provider — a same-day commercial purchase, no government relationship required.
- Declare that mailbox's SMTP + IMAP connection details as this company's "sdi-pec" channel
credentials (
PEC_ADDRESS/PEC_SMTP_*/PEC_IMAP_*/PEC_USERNAME/PEC_PASSWORD/PEC_ID_TRASMITTENTE). - Set
PEC_LIVE=1+ those credentials and runpec.live.spec.ts— the first real run would prove: the FatturaPA XML actually reachessdi01@pec.fatturapa.itover real SMTP, SdI's own first reply (a notifica di scarto/errore, ricevuta di consegna/mancata consegna, or attestazione) actually arrives in the mailbox's IMAP inbox, that reply'sNomeFileactually matches the filename this codebase chose, and (implicitly, by never needing a second PEC address) that the two-step addressing rule was read correctly. None of this has been observed for real yet.
Running in GitHub Actions
Workflow: .github/workflows/compliance-live.yml (manual workflow_dispatch + nightly cron).
- The
livejob runs, against a disposable Postgres:That filter matches exactly the 31cd backend && npx vitest run .live.spec.ts --no-file-parallelism \--reporter=default --reporter=json --outputFile.json=live-results.json*.live.spec.tsfiles (KSeF, PDP, SdI, TSA, Chorus Pro, the payment providers, Polar, the S3/MinIO and OCR round-trips…), each self-gating on its own flag and credentials. No Redis service: none of those 31 files opens a BullMQ/ioredis connection. - A second step then reads
live-results.jsonand fails the job when zero live tests executed. Vitest exits 0 when every suite self-skips, so the exit code alone cannot tell "all gates opened and passed" from "nothing ran at all" - and the second of those is the failure mode this job actually spent months in. A single channel skipping for want of its own secret is still correct and expected; only an entirely empty run is red. - Fixed 2026-09-24 (was:
npx jest live). Jest and ts-jest leftbackend/package.jsonwith the 2026-09-19 Vitest migration, so that command had no local binary:npxdownloadedjest@30from the registry and ran it with no config and no TypeScript transform. Nightly run35972649692(2026-09-24) reportedTest Suites: 35 failed, 35 total/Tests: 0 total- a red job that never executed a single live spec, which is also why the older*-live.spec.ts(hyphen) half of the old pattern is gone from the command above:src/live-spec-naming.spec.tsnow guarantees no such file exists. - Still not reconciled, named honestly rather than fixed silently: the workflow's env block sets
a few flags this codebase no longer reads (
EMAIL_LIVE,PDP_AFNOR_LIVE,COMPLIANCE_LIVE_DB_TESTS) - harmless (nothing consumes them) rather than wrong. Thenational-portals-livejob those notes used to describe no longer exists in the file at all.
Cron caveat: GitHub only fires the
scheduletrigger from the repository's default branch, which in this repository isdev, notmain- so the nightlycron: '0 3 * * *'is already firing (runs35835437074and35972649692). On any other branch it is inert; use the "Run workflow" button (workflow_dispatch) targeting that branch instead.What "green" means with zero secrets configured: every creds-gated spec (KSeF, PDP, SdI, Chorus Pro) self-skips via
liveDescribe— see the hard-success contract at the top of this file, enforced by each spec, not by the gate. One row still runs with no secret at all: the RFC 3161 TSA one, whoseTSA_URLfalls back to the publichttps://freetsa.org/tsrin the workflow when the secret of that name is unset. That is deliberate - it is what keeps the "at least one live spec executed" step above from failing a repository that simply has no credentials yet, while still failing one where the credentials silently stopped reaching the specs. A fully green real-round-trip matrix (KSeF CLEARED, PDP PENDING/CLEARED, SdI CLEARED, …) additionally needs the repo secrets listed in the table below - see also Credentials Guide for the per-platform setup walkthrough.
*_LIVEand*_ENVIRONMENTare constants in the workflow — do NOT add them as GitHub secrets. They are set as literal values directly in the YAML (SDI_LIVE: '1',CHORUSPRO_ENVIRONMENT: 'SANDBOX', etc.). Only real credentials (*_CLIENT_ID,*_CLIENT_SECRET,*_API_KEY,*_AUTH_TOKEN,*_CERTIFICATE,*_CERT_PASSWORD,*_TAXPAYER_ID,*_BASE_URL) belong in secrets.
Where to add the secrets: repo → Settings → Secrets and variables → Actions → New repository secret.
- GitLab equivalent: Settings → CI/CD → Variables.
- Forgejo/Gitea equivalent: Settings → Actions → Secrets (same
${{ secrets.X }}syntax).
3 GitHub-specific gotchas:
- Never run live tests
on: pull_request— GitHub does not expose repository secrets to workflows triggered by PRs from forks, so the secrets would be empty. Useworkflow_dispatch/schedule(as the provided workflow does). - PFX certificates (
SDI_CERTIFICATE,PORTAL_CERTIFICATE) are passed base64-encoded, directly as the secret value (the specs read the base64 string — no file decode needed). Generate withbase64 -w0 cert.pfx | pbcopy(or| xclip). - Optional: create a GitHub Environment named
live-tests(Settings → Environments) with a required reviewer to gate each run before spending an authority call.
Secret names + where each credential comes from:
| Secret(s) | Channel | Where to obtain |
|---|---|---|
KSEF_AUTH_TOKEN, KSEF_NIP | PL KSeF | KSeF app ksef.mf.gov.pl (test: ksef-test.mf.gov.pl) → log in (NIP + trusted profile/qualified sig) → Tokens. Prod also needs the MF prod public PEM keys. |
PDP_BASE_URL, PDP_CLIENT_ID, PDP_CLIENT_SECRET (+ optional PDP_SELLER_ROUTING, PDP_BUYER_ROUTING) | FR PDP | PDP developer portal. Sandbox = superpdp. Real PDP list (annuaire): impots.gouv.fr. |
SDI_ID_TRASMITTENTE, SDI_ENDPOINT, SDI_CERTIFICATE (b64 PFX), SDI_CERT_PASSWORD | IT SdI | Agenzia delle Entrate intermediary accreditation (fatturapa.gov.it) — SDI_ENDPOINT (the accredited SdIRiceviFile URL) and the PFX are both assigned/issued during that accreditation, never a fixed constant (see Credentials Guide §4). Code side: implemented-awaiting-accreditation (sdicoop-client.ts), never yet run against the real endpoint. |
CHORUSPRO_CLIENT_ID, CHORUSPRO_CLIENT_SECRET, CHORUSPRO_TECH_LOGIN, CHORUSPRO_TECH_PASSWORD | FR Chorus Pro B2G | PISTE developer portal (piste.gouv.fr) — subscribe to "API Dépôt flux G2B", then create a Chorus Pro "compte technique" in the sandbox. |
PDP_ACUBE_EMAIL, PDP_ACUBE_PASSWORD | A-Cube (also a Peppol access point) | acubeapi.com/sandbox - a work e-mail, an activation link, a password. Nothing to sign, no purchase obligation. These are ACCOUNT credentials, not a per-integration key, so use a dedicated generated password (see the A-Cube section above). ✅ round-trip proven 2026-09-24. |
CREDENTIALS_ENCRYPTION_KEY | (shared) | openssl rand -hex 32 — same value used by the app's credential store. |
| (none) | Email (document "send" SMTP) | The local Mailpit container the dev/test stack already runs — no secret needed. ✅ proven (see the summary table above). |
National XSD files (not secrets, e.g. PL FA(3)/IT FatturaPA) come from each authority directly.