fix(infakt): implement real PDF download via RegulatoryDocumentReader - #1323
Conversation
InfaktInvoicingAdapter read a pdf_url field that does not exist on Infakt's
invoice resource (verified live against the sandbox), so InvoiceRecord.pdfUrl
was always null and the "download PDF" affordance never worked. Infakt does
expose a real PDF at GET /invoices/{uuid}/pdf.json?document_type=original,
so the adapter now implements RegulatoryDocumentReader for kind 'rendered'
against that endpoint, and the FE Infakt invoice detail section gets a
working Download PDF button wired to the existing /document?kind=rendered
route.
Closes #1321
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
SummarySmall, well-scoped fix: implements Issues[IMPORTANT] —
[SUGGESTION] —
[SUGGESTION] —
Verdict🔄 Approve with changes — the providerInvoiceId null-guard (IMPORTANT) should be fixed before merge to keep behavior consistent with this adapter's own established defensive pattern; the two SUGGESTIONs are optional follow-ups. |
Code Review — PR #1323Scope reviewed: all 12 changed files (Infakt adapter + HTTP client, core FindingsNo BLOCKING, IMPORTANT, or SUGGESTION findings. Verification performed
Positive observations
Verdict: No issues found. This PR looks ready to merge (note: approval was not submitted by this session since it was flagged as a self-approval — this is the same branch/worktree as the PR author). |
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech Lead Review — Approve
Clean, well-scoped fix. The adapter now implements the RegulatoryDocumentReader sub-capability for kind: 'rendered', routing through the existing neutral GET /invoices/:invoiceId/document?kind=rendered controller instead of the phantom pdf_url field. Verified end to end:
- Capability + guard:
getRegulatoryDocument(record, kind)matches the core contract,kinddefaults toconfirmation(consistent with the interface), and unsupported kinds throwUnsupportedRegulatoryDocumentKindError→ the controller maps that to 409. The controller's availability gate (status === 'issued' && regulatoryStatus === 'accepted') is mirrored exactly by the FE button'sacceptedgate, so no dead-end 409s from the UI. - ADR-026 neutrality: Infakt wire vocab (
document_type: 'original',invoice_type: 'vat') stays in the adapter; core only ever sees the neutralkind='rendered'. - No Subiekt regression: dead-field removal is confined to the
InfaktInvoicewire type.InvoiceRecord.pdfUrlstays on core and Subiekt still populates it (subiekt-invoicing.adapter.ts:166); Infakt just returnsnull. - Binary safety:
getBinarystreams with a 10 MB accumulation cap mirroring the KSeF client — an oversized/mendacious body can't drive an unbounded read. - FE: imperative one-shot hook (no raw fetch, goes through
apiClient.invoicing.downloadDocument), object-URL revoke deferred past the click tick. Tests cover the click →downloadDocument(id, 'rendered')path. - Test coverage is solid on both halves (adapter happy / no-content-type / unsupported-confirmation / unsupported-source; http-client happy / non-2xx / oversized-cap; FE click).
Suggestions (non-blocking)
triggerBlobDownload(object-URL +<a download>+ deferred revoke) is now the fifth copy of this primitive acrossfeatures/invoicing/hooksandfeatures/shipments/lib/label-download.ts. Consider extracting a shareddownloadBlob(blob, filename)util. The hook itself is rightly kept separate (documented reason: neutral/documentroute vs. UPO's dedicated route).- Download filename uses the internal
ol-invoice-{id}; a provider invoice number would read nicer for operators. Minor.
Merge order: this is stacked on 1282-infakt-fe-plugin (#1300) — it targets that branch, not main, and must not merge before #1300 lands.
Verdict: Approve.
… correction (#1300) * feat(infakt): register Infakt plugin in API/worker + wire webhook ingestion Registers InfaktIntegrationModule in apps/api and apps/worker so the host can resolve the Infakt 'Invoicing' capability, and wires Infakt's KSeF-relay webhooks into OL's ADR-021/ADR-015 ingestion pipeline: - InfaktInboundWebhookDecoderAdapter (provider-keyed): HMAC-SHA256 verify, subscription-verification handshake echo, envelope extraction - InfaktWebhookEventTranslatorAdapter (adapterKey-keyed): maps send_to_ksef_success/error to the new `invoicing` inbound domain - InboundWebhookDecoderPort grows an optional detectHandshake step (ADR-021) so a provider's subscription-verification ping can echo a body before signature verification — WebhookService/WebhookController thread the return value through - InboundRoutingPolicyService routes `invoicing` events to the existing invoicing.regulatoryStatus.reconcile job (webhook as trigger, not source of truth — the scheduled reconcile still drains the full frontier) Closes #1281 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): address PR #1293 tech review suggestions Shorten the wrap-around import path in both webhook adapters (infrastructure/adapters -> infrastructure/webhooks was going through infrastructure twice), and replace the dummy-secret `new InfaktWebhookTranslator({ secret: '' }, logger)` construction with a named `InfaktWebhookTranslator.forParsing(logger)` factory that makes the secret-independent-parsing-only intent explicit in the type itself. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): correct field names, KSeF submission, and handshake status found in manual QA Manual E2E testing against the real Infakt sandbox surfaced 8 issues blocking real invoice issuance/correction and webhook verification: - upsertCustomer sent `name`/`post_code`; Infakt's v3 API wants `company_name`/`postal_code` (rejected/ignored otherwise) - issueInvoice hardcoded payment_method: 'transfer', which 422s without a configured bank account; defaulted to 'cash' (proven live in the June 30 POC) - issueInvoice/issueCorrection sent client_uuid; Infakt's invoices.json only accepts the numeric client_id - issueCorrection never sent a client at all, so every correction 422'd - empty InvoiceLine.taxRate (core's documented contract) cascaded into a rejection on every single invoice line; added a Polish-standard-VAT (23%) fallback - InfaktInvoicingAdapter.sendToKsef() existed but was never called from issueInvoice/issueCorrection (only a standalone POC script called it directly) - every Infakt invoice sat in `draft` forever. Now called inline, matching how KSeF submits inline and Subiekt transmits natively at issuance. - InfaktInboundWebhookDecoderAdapter.extractEnvelope always routed regardless of event name instead of using the `ignore` outcome for events OL doesn't act on - The webhook handshake echo returned 202; Infakt's own verifier only accepts 200 Confirmed end-to-end against the real sandbox: issue -> auto KSeF submission -> real Infakt webhook (real HMAC) -> reconcile job -> regulatoryStatus=cleared with a real KSeF number, for both the original invoice and its correction. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): disambiguate verification handshake from signed events Address review feedback on #1293: gate getVerificationEcho on the absence of the event envelope so a signed webhook that happens to carry a verification_code field in its resource is never mis-short-circuited into the handshake path. Also cap the echoed verification_code length, since the handshake echo is returned pre-signature-verification and shouldn't reflect an unbounded attacker-controlled value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): resolve rebase conflict duplicating taxRateNumeric Rebasing onto main (which already carries #1292's grossToNet/taxRateNumeric module-level refactor) alongside this branch's own empty-taxRate fix produced a duplicate: an unused private taxRateNumeric method plus the real, still-unpatched module-level function actually called by grossToNet. Fold the empty-string fallback into the module-level function that's on the real call path and drop the dead duplicate; extend the two issueCorrection tests exercising send_to_ksef.json to seed the response now that issueCorrection always submits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): address PR #1293 tech-review findings on KSeF submission - Document the retry-safety assumption behind the two-step issue+submit flow at both sendToKsef call sites: a retry re-POSTs invoices.json with the same external_id, relying on Infakt returning/reusing the same invoice uuid rather than creating a duplicate draft, so the follow-up sendToKsef becomes a safe re-attempt on the same document. - Add error-path tests for issueInvoice and issueCorrection covering sendToKsef failing after a successful invoice creation, asserting the InfaktApiError/failureMode propagates. - payment_method being hardcoded to 'cash' is tracked separately as #1303 (no neutral paymentMethod field exists on IssueInvoiceCommand yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): register ConnectionTesterPort for infakt.accounting.v1 Found during a live E2E walkthrough of the FE plugin (PR #1300): "Test connection" fails with "Connection testing is not supported for adapter infakt.accounting.v1" because infakt-plugin.ts never registered a ConnectionTesterPort. Every other adapter with a Test connection affordance (Erli, WooCommerce, PrestaShop, InPost, Allegro, Subiekt) has one; Infakt was the one gap. Add InfaktConnectionTesterAdapter mirroring SubiektConnectionTesterAdapter's pattern (Infakt's factory only exposes createInvoicingAdapter, not a bare HTTP-client construction seam, so credentials are resolved and the client built directly rather than via a factory method): probes GET clients.json (cheap, side-effect-free, requires a valid key), maps InfaktApiError to the neutral ConnectionTestResult, and never throws. Register it in infakt-plugin.ts alongside the existing validator/retry-classifier/webhook registrations. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): send invoice amounts as plain-integer groszy, not decimal strings Critical fiscal bug found during live E2E against the real inFakt sandbox: issued invoices landed on inFakt's dashboard (and were submitted to KSeF) at ~1/100th of the real order total (e.g. a PLN 349.00 order → PLN 3.48 on the resulting invoice). Confirmed against both the raw sandbox response (net_price/tax_price/gross_price returned as plain integers, e.g. 283 for PLN 2.83) and the official Infakt API schema (unit_net_price/net_price/ gross_price are `integer`, documented "w groszach") that inFakt's wire format is a plain integer count of groszy (1 PLN = 100 groszy) everywhere - not the "amount currency" decimal-string format (e.g. "283.74 PLN") the adapter was previously sending, which the earlier #1292 review had incorrectly confirmed against the schema. - InfaktInvoice.gross_price/net_price/tax_price and InfaktInvoiceService.unit_net_price/net_price/tax_price/gross_price are now typed `number` (plain integer groszy), not `string`. - Added toGroszy/fromGroszy helpers replacing the removed parseInfaktAmount; issueInvoice and issueCorrection now round PLN amounts to integer groszy when building the request payload, and convert the original invoice's groszy amounts back to PLN decimals before doing gross-to-net arithmetic. - Updated all affected unit tests (fixtures + assertions) to the integer groszy format and to assert exact groszy values rather than decimal strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): polish remaining review suggestions on PR #1293 Addresses the three outstanding SUGGESTIONs from the full-PR re-review (2026-07-01T22:11:12Z), none of which were blocking: - infakt-inbound-webhook-decoder.adapter.ts: drop the redundant parenthesization around parsed.resource['invoice_uuid'] flagged as a merge-artifact. - webhook.controller.ts: add a one-line comment pointing at the res.status(HttpStatus.OK) call, since @res is the only place this controller reaches for the raw Response object. - infakt-invoicing.adapter.ts: clarify sendToKsef's public-visibility rationale — it already has a real external caller (scripts/poc-sandbox-test.ts), not just a hypothetical future one. Verified: pnpm --filter @openlinker/integrations-infakt test (108 passing), pnpm --filter @openlinker/api test -- webhook (608 passing), type-check clean on both packages, targeted eslint clean on all three files. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): map KSeF success status to accepted, not cleared Found during a live E2E walkthrough (letting the real scheduled invoicing.regulatoryStatus.reconcile job pick up clearance, rather than manually poking the DB): toRegulatoryStatus mapped Infakt's terminal ksef_data.status: 'success' to the neutral 'cleared' instead of 'accepted'. Per the core RegulatoryStatus contract, 'cleared' is reserved for split-clearance regimes that no current provider emits; the FE's status card only branches on submitted/accepted/rejected, so an invoice stuck at 'cleared' rendered as a permanently in-progress "CLEARING" badge with no clearance-reference chip, even though the invoice had genuinely cleared on the government side. KSeF's own adapter already maps its terminal 200 status to 'accepted' for the identical reason - Infakt now mirrors that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(plans): implementation plan for inFakt FE plugin + invoice-section redesign Covers issue #1282 (guided setup, credentials rotation, invoice detail section, KOR correction flow) plus the OrderInvoicePanel regulatory-section host-chrome redesign validated in the approved mockup. Corrects the issue's original setup-flow description against verified codebase precedent (Erli's guided-route pattern, not the generic inline create form). Closes #1282 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(plans): add E2E Playwright evidence phase to inFakt FE plan Adds Phase 4: two Playwright walkthrough scripts (connection setup, invoice issuance through real KSeF clearance + a correction) mirroring the existing Subiekt/Erli screenshot-evidence convention, plus posting the resulting docs/assets/infakt/*.png screenshots inline in a PR comment as end-to-end proof. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): inFakt FE plugin — connection setup + invoice detail + KOR correction Implements the frontend half of the inFakt accounting integration (epic #1279): guided connection setup (name + API key + optional sandbox baseUrl, mirroring the Erli pattern), credentials rotation, post-create baseUrl editing, and the invoice-surfacing slots (regulatory status + KSeF number, KOR correction flow) driven by the existing generic InvoicingPort/CorrectionIssuer backend contract (PR #1292/#1293). Also introduces a shared `.reg-card` severity-stripe treatment for the invoiceDetailSection provider slot (additive alongside each section's existing root class, so KSeF/Subiekt keep their current DOM shape and tests) — inFakt ships with it from day one, KSeF/Subiekt adopt it as a drive-by improvement. Closes #1282 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * test(web): add inFakt E2E screenshot-evidence scripts Playwright walkthroughs (apps/web/e2e/infakt-connection.mjs, infakt-invoice.mjs) mirroring the existing Subiekt/Erli proof-capture convention. Not part of pnpm test / CI — manual-run evidence scripts against a live stack + the real inFakt sandbox, producing docs/assets/infakt/*.png for the PR comment and the future operator setup guide. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): add first 4 manual inFakt-dashboard walkthrough screenshots Part of the E2E evidence trail for PR #1300 / issue #1282: inFakt sandbox dashboard login, API key generation page, and the webhook creation flow (list + new-webhook form). Captured manually against the real inFakt sandbox dashboard, no secrets visible in frame. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): add real inFakt E2E screenshots (connection, issuance, KOR, list) Captured against the real inFakt sandbox on a temporary paired stack (this branch's web build + the 1281-infakt-plugin-registration-webhook backend branch), confirming the full connect -> issue -> KSeF-clearance -> correction flow works end to end through OpenLinker: - 00-05: guided connection setup, Test connection (passing, after the ConnectionTesterPort fix landed on PR #1293), connections list - 12: invoice accepted with real KSeF clearance number (8201194127-20260701-A5797F400000-ED) - 14-16: KOR correction flow, also cleared by the real sandbox - 17: invoices list showing the original + correction, both accepted - if1-if4: manual inFakt-dashboard screenshots (API key, webhooks) Known gaps (tracked, not blocking): - 13-invoice-detail-page was captured via the /invoices/:invoiceId page, but that GET route doesn't exist yet on the 1281 backend branch's base main snapshot — will be trivial to recapture once #1292/#1293 land on current main. - The not-issued / submitted (pending) order-detail states aren't captured cleanly yet — the seeded test order is now terminal (accepted) for this connection; a second seeded order would be needed for a clean before/during capture. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): drop broken invoice-detail-page screenshot (route missing on this backend branch, not blocking) Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): re-capture E2E screenshots with the money-format fix verified Re-ran the full sandbox walkthrough on a fresh order (PLN 189.00) after the groszy/decimal-string fix landed on PR #1293 (651cac2). Confirmed against inFakt's raw API response that amounts now round-trip exactly (gross_price: 18900 groszy = PLN 189.00) and the KOR correction combines original + corrected lines correctly (189.00 + 99.99 = 288.99 PLN). Also adds the invoice-detail-page screenshot (13) that 404'd on a stale branch snapshot last time — the route works fine now. Replaces the earlier PLN 349.00 test order's screenshots, which (correctly, at the time) showed the ~100x-low amounts that led to the money-format bug report on PR #1293. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): move E2E screenshots to libs/integrations/infakt/docs/assets/ Aligns with the convention established in PR #1284 (KSeF/Subiekt tutorials) — screenshot assets live inside the integration package's own docs/assets/, not a root-level docs/assets/<provider>/ directory. Updates the two e2e scripts' output path accordingly. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): add clean not-issued/submitted screenshots + fresh Part 6 confirmation - 08-orders-list.png: clean, filtered (sourceConnectionId + createdFrom) view of just the seeded test orders — the unfiltered list is too cluttered with other sessions' dev-DB fixtures to be tutorial-usable. - 10/11: genuinely clean not-issued -> submitted transition, captured without a page reload wiping the connection-picker selection (fixed infakt-invoice.mjs to shoot the submitted state before any reload). - if5-infakt-invoice-confirmed.png: fresh manual inFakt-dashboard screenshot confirming the money-format fix (9/07/2026 = PLN 189.00, 10/07/2026 correction = PLN 288.99), replacing the pre-fix evidence. - 13/17 updated: also documents the KSeF cleared-vs-accepted mapping bug found this run (flagged on PR #1293) — two rows show "KSeF: CLEARING" (the real reconcile job's output, unpatched) alongside two "KSeF: ACCEPTED" rows from earlier runs where I'd manually corrected the DB while chasing the money bug. Will re-capture once the accepted-mapping fix lands. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web): address inFakt FE plugin tech-review findings Ports the KSeF correction flow's line-delta validation guard into the inFakt correction flow so a filled line number without a quantity or price change is rejected client-side (matching the backend's HasCorrectionDeltaConstraint) instead of silently 400ing. Extracts the `.reg-card` tone mapping duplicated across KSeF, Subiekt, and inFakt detail sections into a single `regCardToneFor` helper on the invoicing feature barrel. Also applies the review's non-blocking suggestions: narrows the helper's return type, makes InvoiceCorrectionFlowProps.connection optional (no implementer reads it), and gives the `.textarea` class and `.reg-card` note paragraphs real CSS rules instead of relying on bare element selectors / inline styles. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): re-capture screenshots with the cleared->accepted fix verified Fourth fresh sandbox order (PLN 259.00), confirming both backend fixes together: gross_price round-trips exactly (25900 groszy = PLN 259.00), and the invoice now shows "KSeF: ACCEPTED" with a real clearance reference chip instead of getting stuck on "KSeF: CLEARING" forever. Correction verified too (259.00 + 99.99 = 358.99 PLN exact, gross_price 35899 groszy). Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): Infakt default-payment-method picker (wizard + edit disclosure) Adds a "Default payment method" field to the inFakt connection wizard (defaults to Transfer, the common case for online-shop invoicing) and the equivalent field to the edit-connection screen, tucked behind a new InlineDisclosure primitive (shared/ui) so it reads as an inline fact ("Payment method for invoice: Cash") rather than a permanently- open control. Wires apps/web/src/features/connections/components/ infakt-setup-form.tsx, infakt-setup.schema.ts, edit-connection.schema.ts, EditConnectionForm.tsx and plugins/infakt/components/infakt-structured- section.tsx to the config.defaultPaymentMethod field introduced by #1303. Related to #1303. Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): re-verify screenshots against current code post tech-review (order PLN 899.00) Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(infakt): re-capture connection wizard screenshots with payment-method field Rebuilt the web bundle after pulling the default-payment-method picker feature (#1303) and re-ran the connection walkthrough — the wizard, created/test/list states now show the new "Default payment method" field. Live-verified against the real sandbox (Test connection passes). Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web): address inFakt FE plugin PR #1300 tech-review findings Default the inFakt setup wizard's payment method to cash - transfer 422s on inFakt unless a bank account is configured, contradicting the help copy and the adapter's own fallback. Also syncs the stale lazy-route count comment (49 total, 10 plugin routes) and drops two unused .reg-card CSS classes plus a raw-px rule in favor of the rem convention. Signed-off-by: Norbert Kulus <norbert.kulus@silksh.pl> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(infakt): implement real PDF download via RegulatoryDocumentReader (#1323) fix(infakt): implement real PDF download via RegulatoryDocumentReader (#1323) InfaktInvoicingAdapter read a pdf_url field that does not exist on Infakt's invoice resource (verified live against the sandbox), so InvoiceRecord.pdfUrl was always null and the "download PDF" affordance never worked. Infakt does expose a real PDF at GET /invoices/{uuid}/pdf.json?document_type=original, so the adapter now implements RegulatoryDocumentReader for kind 'rendered' against that endpoint, and the FE Infakt invoice detail section gets a working Download PDF button wired to the existing /document?kind=rendered route. Closes #1321 --------- Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> Signed-off-by: Norbert Kulus <norbert.kulus@silksh.pl> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
InfaktInvoicingAdapterread apdf_urlfield that does not exist on Infakt's invoice resource (verified live against the sandbox), soInvoiceRecord.pdfUrlwas alwaysnulland the "download PDF" affordance never worked for Infakt connections.RegulatoryDocumentReader.getRegulatoryDocumentforkind: 'rendered', calling the realGET /invoices/{uuid}/pdf.json?document_type=original&invoice_type={kind}endpoint (confirmed live: returns a valid PDF binary directly).pdf_url/print_urlfields are removed from the Infakt wire type;InvoiceRecord.pdfUrlstays on core (Subiekt's FE still reads it) but Infakt no longer fakes it — it'snullnow, with the real path routed through the existingGET /invoices/:invoiceId/document?kind=renderedcontroller route (unchanged, already supports this).useInvoiceRenderedDocumentDownloadhook + a working "Download PDF" button in the Infakt invoice detail section.Base branch is
1282-infakt-fe-plugin(PR #1300, still open) because the touched FE component (infakt-invoice-detail-section.tsx) only exists on that branch — will retarget tomainonce it merges, matching PR #1307's precedent.Test plan
pnpm --filter @openlinker/integrations-infakt build/lint/test— 115/115 passing, newgetRegulatoryDocument+getBinarycoverage (happy path, no-content-type fallback, unsupported kind, oversized-body cap)pnpm --filter @openlinker/web type-checkcleanapiClient.invoicing.downloadDocument(id, 'rendered')pnpm check:invariantsclean (no cross-context/service-interface violations)Closes #1321