From 1a5fba9c4173d6d28a4d856ceabfb012bf4e1476 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 14:18:59 +0200 Subject: [PATCH 01/27] feat(infakt): register Infakt plugin in API/worker + wire webhook ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/package.json | 1 + apps/api/src/plugins.ts | 3 + .../interfaces/webhook.service.interface.ts | 12 +- .../services/webhook.service.spec.ts | 34 ++++++ .../application/services/webhook.service.ts | 16 ++- .../src/webhooks/http/webhook.controller.ts | 4 +- apps/api/test/jest-integration.cjs | 8 ++ apps/worker/package.json | 1 + apps/worker/src/plugins.ts | 4 + apps/worker/test/jest-integration.cjs | 8 ++ ...lan-infakt-registration-webhook-routing.md | 114 +++++++++++++++++ .../ports/inbound-webhook-decoder.port.ts | 11 ++ .../types/canonical-inbound-event.types.ts | 8 +- .../inbound-routing-policy.service.spec.ts | 29 +++++ .../inbound-routing-policy.service.ts | 23 ++++ libs/integrations/infakt/src/index.ts | 5 + .../infakt/src/infakt-integration.module.ts | 6 +- libs/integrations/infakt/src/infakt-plugin.ts | 16 +++ ...kt-inbound-webhook-decoder.adapter.spec.ts | 115 ++++++++++++++++++ ...t-webhook-event-translator.adapter.spec.ts | 52 ++++++++ .../infakt-inbound-webhook-decoder.adapter.ts | 74 +++++++++++ ...infakt-webhook-event-translator.adapter.ts | 45 +++++++ pnpm-lock.yaml | 6 + 23 files changed, 583 insertions(+), 12 deletions(-) create mode 100644 docs/plans/implementation-plan-infakt-registration-webhook-routing.md create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-webhook-event-translator.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts diff --git a/apps/api/package.json b/apps/api/package.json index 6a57ff953..366d0f809 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,6 +43,7 @@ "@openlinker/integrations-allegro": "workspace:*", "@openlinker/integrations-dpd-polska": "workspace:*", "@openlinker/integrations-erli": "workspace:*", + "@openlinker/integrations-infakt": "workspace:*", "@openlinker/integrations-ksef": "workspace:*", "@openlinker/integrations-inpost": "workspace:*", "@openlinker/integrations-prestashop": "workspace:*", diff --git a/apps/api/src/plugins.ts b/apps/api/src/plugins.ts index d5ed53bbd..f9ab50227 100644 --- a/apps/api/src/plugins.ts +++ b/apps/api/src/plugins.ts @@ -36,6 +36,7 @@ import { WooCommerceIntegrationModule } from '@openlinker/integrations-woocommer import { ErliIntegrationModule } from '@openlinker/integrations-erli'; import { KsefIntegrationModule } from '@openlinker/integrations-ksef'; import { SubiektIntegrationModule } from '@openlinker/integrations-subiekt'; +import { InfaktIntegrationModule } from '@openlinker/integrations-infakt'; export const apiPlugins: PluginEntry[] = [ PrestashopIntegrationModule, @@ -49,4 +50,6 @@ export const apiPlugins: PluginEntry[] = [ // #753: Subiekt nexo invoicing adapter — registered so the host can resolve // the 'Invoicing' capability for subiekt connections. SubiektIntegrationModule, + // #1281: Infakt accounting invoicing adapter (KSeF submitted via Infakt). + InfaktIntegrationModule, ]; diff --git a/apps/api/src/webhooks/application/interfaces/webhook.service.interface.ts b/apps/api/src/webhooks/application/interfaces/webhook.service.interface.ts index 9904b3a41..2b0d71452 100644 --- a/apps/api/src/webhooks/application/interfaces/webhook.service.interface.ts +++ b/apps/api/src/webhooks/application/interfaces/webhook.service.interface.ts @@ -15,14 +15,18 @@ export interface IWebhookService { * Orchestrates the complete webhook processing flow: * 1. Resolve the per-provider decoder (default = OL-HMAC + WebhookRequestDto) * 2. Connection gate (exists, active, platformType matches) - * 3. Verify signature via the decoder; replay-check the normalized timestamp - * 4. Decode the body → route | ignore (202, no publish) | reject (400) - * 5. Dedup gate → publish event → record delivery + * 3. Subscription-verification handshake — if detected, return its echo + * body immediately (no verify/dedup/publish) + * 4. Verify signature via the decoder; replay-check the normalized timestamp + * 5. Decode the body → route | ignore (202, no publish) | reject (400) + * 6. Dedup gate → publish event → record delivery * * @param provider - Provider identifier (e.g., 'prestashop', 'inpost') * @param connectionId - Connection identifier (UUID) * @param rawBody - Raw request body bytes (the decoder verifies + parses these) * @param headers - Request headers (provider-specific signature/timestamp/topic) + * @returns the handshake echo body when the request is a subscription + * verification ping; otherwise resolves with no value * @throws WebhookAuthenticationException if signature/connection is invalid (401) * @throws WebhookReplayException if timestamp is out of window (401) * @throws WebhookDecodeException if the body can't be decoded (400) @@ -32,5 +36,5 @@ export interface IWebhookService { connectionId: string, rawBody: Buffer, headers: Record - ): Promise; + ): Promise | void>; } diff --git a/apps/api/src/webhooks/application/services/webhook.service.spec.ts b/apps/api/src/webhooks/application/services/webhook.service.spec.ts index 97517f916..dd38e5255 100644 --- a/apps/api/src/webhooks/application/services/webhook.service.spec.ts +++ b/apps/api/src/webhooks/application/services/webhook.service.spec.ts @@ -134,6 +134,40 @@ describe('WebhookService (ADR-021 decoder dispatch + #711 dedup/recovery)', () = service = module.get(WebhookService); }); + describe('subscription-verification handshake', () => { + it('returns the handshake echo body and short-circuits before verify/dedup/publish', async () => { + const handshakeDecoder: jest.Mocked = { + ...decoder, + detectHandshake: jest.fn().mockReturnValue({ verification_code: 'abc123' }), + }; + decoderRegistry.get.mockReturnValue(handshakeDecoder); + + const result = await service.processWebhook(provider, connectionId, rawBody, headers); + + expect(result).toEqual({ verification_code: 'abc123' }); + expect(handshakeDecoder.verify).not.toHaveBeenCalled(); + expect(deliveryRepository.insertIfNew).not.toHaveBeenCalled(); + expect(eventPublisher.publishInboundWebhook).not.toHaveBeenCalled(); + }); + + it('proceeds to verify when detectHandshake returns null', async () => { + const nonHandshakeDecoder: jest.Mocked = { + ...decoder, + detectHandshake: jest.fn().mockReturnValue(null), + }; + decoderRegistry.get.mockReturnValue(nonHandshakeDecoder); + deliveryRepository.insertIfNew.mockResolvedValue({ + isNew: true, + delivery: makeDelivery({ eventId: 'e1', provider, connectionId }), + }); + + const result = await service.processWebhook(provider, connectionId, rawBody, headers); + + expect(result).toBeUndefined(); + expect(nonHandshakeDecoder.verify).toHaveBeenCalled(); + }); + }); + describe('decoder dispatch + three-state decode', () => { it('publishes a routed event when the decoder verifies + routes and the row is new', async () => { deliveryRepository.insertIfNew.mockResolvedValue({ diff --git a/apps/api/src/webhooks/application/services/webhook.service.ts b/apps/api/src/webhooks/application/services/webhook.service.ts index 3d3f9ca62..554311ce5 100644 --- a/apps/api/src/webhooks/application/services/webhook.service.ts +++ b/apps/api/src/webhooks/application/services/webhook.service.ts @@ -17,6 +17,7 @@ import { DefaultWebhookDecoder } from '../decoders/default-webhook-decoder'; import { WebhookAuthenticationException } from '../errors/webhook-authentication.exception'; import { WebhookDecodeException } from '../errors/webhook-decode.exception'; import type { InboundWebhookEvent } from '@openlinker/core/events'; +import type { InboundWebhookDecoderPort } from '@openlinker/core/integrations'; import { InboundWebhookDecoderRegistryService, INBOUND_WEBHOOK_DECODER_REGISTRY_TOKEN, @@ -58,14 +59,25 @@ export class WebhookService implements IWebhookService { connectionId: string, rawBody: Buffer, headers: Record - ): Promise { + ): Promise | void> { // Resolve the provider's decoder (ADR-021); fall back to the host's // OL-HMAC + WebhookRequestDto default for OL-module providers. - const decoder = this.decoderRegistry.get(provider) ?? this.defaultDecoder; + const decoder: InboundWebhookDecoderPort = + this.decoderRegistry.get(provider) ?? this.defaultDecoder; // Connection gate (provider-agnostic): exists, active, platformType matches. await this.authService.assertConnectionUsable(provider, connectionId); + // Subscription-verification handshake (e.g. Infakt's `verification_code` + // echo) — runs BEFORE signature verification: the ping precedes any + // signed traffic. Short-circuits with the exact body to echo; no + // verify/dedup/publish for a handshake request. + const handshakeBody = decoder.detectHandshake?.(rawBody, headers); + if (handshakeBody) { + this.logger.log(`Webhook handshake detected: provider=${provider}, connectionId=${connectionId}`); + return handshakeBody; + } + // Verify the signature via the decoder (host supplies the per-connection // secret). Then replay-check the decoder-normalized timestamp. Order is // verify → replay because a third-party timestamp is only trusted once the diff --git a/apps/api/src/webhooks/http/webhook.controller.ts b/apps/api/src/webhooks/http/webhook.controller.ts index 64f3ecf76..15419b73b 100644 --- a/apps/api/src/webhooks/http/webhook.controller.ts +++ b/apps/api/src/webhooks/http/webhook.controller.ts @@ -67,7 +67,7 @@ export class WebhookController { @Param('connectionId') connectionId: string, @Headers() headers: Record, @Req() req: RequestWithRawBody, - ): Promise { + ): Promise | void> { // No `@Body() WebhookRequestDto` — the body shape is the provider's, not // OL's (ADR-021). The per-provider decoder (resolved in WebhookService) // verifies + parses the raw bytes; the host OL-module default decoder still @@ -100,7 +100,7 @@ export class WebhookController { } try { - await this.webhookService.processWebhook(provider, connectionId, rawBody, headers); + return await this.webhookService.processWebhook(provider, connectionId, rawBody, headers); } catch (error) { // Map domain exceptions to HTTP exceptions if (error instanceof WebhookDecodeException) { diff --git a/apps/api/test/jest-integration.cjs b/apps/api/test/jest-integration.cjs index c5bf251aa..552b29625 100644 --- a/apps/api/test/jest-integration.cjs +++ b/apps/api/test/jest-integration.cjs @@ -105,6 +105,14 @@ module.exports = { __dirname, '../../../libs/integrations/dpd-polska/src/$1', ), + '^@openlinker/integrations-infakt$': path.resolve( + __dirname, + '../../../libs/integrations/infakt/src/index.ts', + ), + '^@openlinker/integrations-infakt/(.*)$': path.resolve( + __dirname, + '../../../libs/integrations/infakt/src/$1', + ), '^@openlinker/test-kit$': path.resolve(__dirname, '../../../libs/test-kit/src/index.ts'), '^@openlinker/test-kit/(.*)$': path.resolve(__dirname, '../../../libs/test-kit/src/$1'), }, diff --git a/apps/worker/package.json b/apps/worker/package.json index c82aa8a9c..ed7df0d62 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -28,6 +28,7 @@ "@openlinker/integrations-allegro": "workspace:*", "@openlinker/integrations-dpd-polska": "workspace:*", "@openlinker/integrations-erli": "workspace:*", + "@openlinker/integrations-infakt": "workspace:*", "@openlinker/integrations-ksef": "workspace:*", "@openlinker/integrations-inpost": "workspace:*", "@openlinker/integrations-prestashop": "workspace:*", diff --git a/apps/worker/src/plugins.ts b/apps/worker/src/plugins.ts index f7fee3012..b4f5e72ea 100644 --- a/apps/worker/src/plugins.ts +++ b/apps/worker/src/plugins.ts @@ -37,6 +37,7 @@ import { DpdIntegrationModule } from '@openlinker/integrations-dpd-polska'; import { ErliIntegrationModule } from '@openlinker/integrations-erli'; import { KsefIntegrationModule } from '@openlinker/integrations-ksef'; import { SubiektIntegrationModule } from '@openlinker/integrations-subiekt'; +import { InfaktIntegrationModule } from '@openlinker/integrations-infakt'; export const workerPlugins: PluginEntry[] = [ PrestashopIntegrationModule, @@ -53,4 +54,7 @@ export const workerPlugins: PluginEntry[] = [ // #753: resolve the Subiekt 'Invoicing' capability when issuance is driven // from the worker (mirrors WooCommerce/InPost dual registration). SubiektIntegrationModule, + // #1281: resolve the Infakt 'Invoicing' capability when issuance/reconcile + // jobs run from the worker (mirrors Subiekt/KSeF dual registration). + InfaktIntegrationModule, ]; diff --git a/apps/worker/test/jest-integration.cjs b/apps/worker/test/jest-integration.cjs index c95ef6780..7bc71cc72 100644 --- a/apps/worker/test/jest-integration.cjs +++ b/apps/worker/test/jest-integration.cjs @@ -101,5 +101,13 @@ module.exports = { __dirname, '../../../libs/integrations/dpd-polska/src/$1', ), + '^@openlinker/integrations-infakt$': path.resolve( + __dirname, + '../../../libs/integrations/infakt/src/index.ts', + ), + '^@openlinker/integrations-infakt/(.*)$': path.resolve( + __dirname, + '../../../libs/integrations/infakt/src/$1', + ), }, }; diff --git a/docs/plans/implementation-plan-infakt-registration-webhook-routing.md b/docs/plans/implementation-plan-infakt-registration-webhook-routing.md new file mode 100644 index 000000000..ec7146a74 --- /dev/null +++ b/docs/plans/implementation-plan-infakt-registration-webhook-routing.md @@ -0,0 +1,114 @@ +# Implementation Plan — Infakt plugin registration + webhook routing (#1281) + +## 1. Goal / non-goals + +Wire the already-hardened Infakt plugin (#1280, PR #1292, not yet merged — this +branch is stacked on `1280-infakt-plugin-hardening-tests`) into the running API, +and connect Infakt's inbound webhooks (KSeF clearance notifications relayed by +Infakt) to OL's webhook ingestion pipeline. + +Non-goals: `WebhookProvisioningPort` (Infakt webhook setup is UI-only, per +issue scope), FE plugin (#1282), docs/ADR (#1283). + +## 2. Layer classification + +Integration (`libs/integrations/infakt`) + thin core extension +(`libs/core/src/integrations`, `libs/core/src/sync` — additive domain value + +routing case) + host wiring (`apps/api`). + +## 3. Key finding from research + +- `libs/integrations/infakt` only exists on `1280-infakt-plugin-hardening-tests` + (unmerged). This branch is based on it per user instruction; PR opens + against `main` as **draft** and will show #1280's diff until #1292 merges. +- Registering a `WebhookEventTranslatorPort` alone is not enough: Infakt's + webhook is **not** OL-enveloped and uses its own HMAC scheme + a + verification handshake (`{"verification_code":...}` echo) — this is exactly + the third-party-native case ADR-021's `InboundWebhookDecoderPort` exists for + (see InPost as the precedent). +- The existing `InboundEventDomainValues` closed union + (`order | inventory | product | shipment`) has no `invoicing` value, and + `InboundRoutingPolicyService` has no case for it. Reconciling clearance + status already has a job: `invoicing.regulatoryStatus.reconcile` + (page-scan over non-terminal invoices, no target-id payload) — a webhook + is a **trigger, not the source of truth** (matches the InPost/webhook + philosophy already documented in `architecture-overview.md`), so routing + the Infakt event to this existing reconcile job (rather than inventing a + by-id job) is the smallest correct change. +- No existing mechanism lets a decoder produce a custom response body + (needed for the verification-code echo). Adding one optional method to + `InboundWebhookDecoderPort` is the minimal extension; it's additive so + `DefaultWebhookDecoder` / InPost's decoder need no change (default to + no-op via `?.()`). + +## 4. Steps + +1. **Dependency wiring** + - `apps/api/package.json`, `apps/worker/package.json`: add + `"@openlinker/integrations-infakt": "workspace:*"`. + - `apps/api/src/plugins.ts`: import + append `InfaktIntegrationModule`. + - `apps/api/test/jest-integration.cjs`: add the two moduleNameMapper + entries (mirrors the ksef block). + +2. **Handshake extension (core, additive)** + - `libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts`: + add optional `detectHandshake?(rawBody: Buffer, headers: Record): Record | null` — runs before signature + verification (a subscription-verification ping predates any real + traffic and isn't itself required to be signed by Infakt's documented + flow). + - `apps/api/src/webhooks/application/services/webhook.service.ts`: after + the connection gate, call `decoder.detectHandshake?.(rawBody, headers)`; + if it returns non-null, return it immediately (no verify/dedup/publish). + - `IWebhookService.processWebhook` return type: `Promise | void>`. + - `WebhookController.receiveWebhook`: return type updated to match; + NestJS serializes the returned object as the JSON body under the + existing `@HttpCode(ACCEPTED)`. + +3. **`invoicing` inbound domain (core, additive)** + - `libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts`: + add `'invoicing'` to `InboundEventDomainValues`. + - `libs/core/src/sync/application/services/inbound-routing-policy.service.ts`: + add a `case 'invoicing':` → `{ jobType: 'invoicing.regulatoryStatus.reconcile', + requiredCapability: 'Invoicing', payload: { schemaVersion: 1, limit: 50 } + satisfies RegulatoryStatusReconcilePayloadV1 }`. + +4. **Infakt decoder + translator** (`libs/integrations/infakt/src/infrastructure/adapters/`) + - `infakt-inbound-webhook-decoder.adapter.ts` — + `InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoderPort`: + wraps the existing `InfaktWebhookTranslator` (verify via HMAC-SHA256 hex + over raw body per its `verifySignature`; `detectHandshake` via its + `getVerificationEcho`; `extractEnvelope` via its `parse`, mapping to + `{eventId: event.uuid, eventType: event.name, occurredAt: + event.created_at, objectType: 'invoice', externalId: + resource.invoice_uuid ?? event.uuid, payload: resource}`). + - `infakt-webhook-event-translator.adapter.ts` — + `InfaktWebhookEventTranslatorAdapter implements WebhookEventTranslatorPort`: + `translate` returns `{domain: 'invoicing', externalId, eventType, + occurredAt, payload}` for `objectType === 'invoice'`, else `null`. + - Register both in `infakt-plugin.ts`'s `register(host)`: + `host.inboundWebhookDecoderRegistry.register(platformType, decoder)`, + `host.webhookEventTranslatorRegistry.register(adapterKey, translator)`. + +5. **Tests** + - Unit specs for the two new adapters (happy path + malformed/unsigned + rejection + handshake echo). + - Extend `inbound-routing-policy.service.spec.ts` with the new + `'invoicing'` case. + - Extend `webhook.service.spec.ts` with a handshake-short-circuit case. + +6. **Quality gate**: `pnpm lint && pnpm type-check && pnpm test` + (scoped to affected packages given resource constraints; full run before + PR). + +## 5. Risks / open questions + +- Exact HTTP status Infakt expects for the verification echo is unconfirmed + (POC never shipped a controller for it) — using the existing `202` path. + Flagged in the PR description as a manual-verification item, consistent + with #1292's own test-plan checkboxes. +- `invoicing.regulatoryStatus.reconcile`'s `limit: 50` on webhook-triggered + runs is a nudge, not a guarantee the specific invoice is within that page — + acceptable because the scheduled reconcile already drains the full + frontier; the webhook only shortens latency. diff --git a/libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts b/libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts index 706493611..9322ea4e6 100644 --- a/libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts +++ b/libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts @@ -42,4 +42,15 @@ export interface InboundWebhookDecoderPort { * (malformed). Must be total — never throw unbounded. */ extractEnvelope(rawBody: Buffer, headers: Record): DecodeResult; + + /** + * Detect a provider-native subscription-verification handshake (e.g. + * Infakt's `{"verification_code": "..."}` ping the endpoint must echo back + * to activate the webhook) and return the exact JSON body to echo, or + * `null` if this isn't a handshake request. Runs BEFORE `verify` — a + * handshake ping precedes any signed traffic and predates a rotated + * secret being meaningful. Optional: providers without a handshake step + * omit it, and the host skips straight to `verify`. + */ + detectHandshake?(rawBody: Buffer, headers: Record): Record | null; } diff --git a/libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts b/libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts index 219b2ea08..7b9d71727 100644 --- a/libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts +++ b/libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts @@ -17,7 +17,13 @@ * @module libs/core/src/integrations/domain/types */ -export const InboundEventDomainValues = ['order', 'inventory', 'product', 'shipment'] as const; +export const InboundEventDomainValues = [ + 'order', + 'inventory', + 'product', + 'shipment', + 'invoicing', +] as const; export type InboundEventDomain = (typeof InboundEventDomainValues)[number]; diff --git a/libs/core/src/sync/application/services/__tests__/inbound-routing-policy.service.spec.ts b/libs/core/src/sync/application/services/__tests__/inbound-routing-policy.service.spec.ts index 5f7a6bc75..b0e2e6f34 100644 --- a/libs/core/src/sync/application/services/__tests__/inbound-routing-policy.service.spec.ts +++ b/libs/core/src/sync/application/services/__tests__/inbound-routing-policy.service.spec.ts @@ -119,6 +119,35 @@ describe('InboundRoutingPolicyService', () => { ); }); + it('should route an invoicing event to invoicing.regulatoryStatus.reconcile gated on Invoicing', async () => { + const outcome = await service.route( + event({ domain: 'invoicing', eventType: 'send_to_ksef_success', externalId: 'inv-1' }), + connection(['Invoicing']), + ['Invoicing'], + 'evt-9' + ); + + expect(outcome.status).toBe('enqueued'); + expect(jobEnqueue.enqueueJob).toHaveBeenCalledWith( + expect.objectContaining({ + jobType: 'invoicing.regulatoryStatus.reconcile', + payload: { schemaVersion: 1, limit: 50 }, + }) + ); + }); + + it('should not enqueue an invoicing event when Invoicing is not enabled', async () => { + const outcome = await service.route( + event({ domain: 'invoicing', eventType: 'send_to_ksef_success' }), + connection([]), + ['Invoicing'], + 'evt-9' + ); + + expect(outcome).toEqual({ status: 'ungated', domain: 'invoicing', requiredCapability: 'Invoicing' }); + expect(jobEnqueue.enqueueJob).not.toHaveBeenCalled(); + }); + it('should not enqueue a shipment event when ShippingProviderManager is not enabled', async () => { const outcome = await service.route( event({ domain: 'shipment', eventType: 'tracking' }), diff --git a/libs/core/src/sync/application/services/inbound-routing-policy.service.ts b/libs/core/src/sync/application/services/inbound-routing-policy.service.ts index b9b1b084e..fff1968e1 100644 --- a/libs/core/src/sync/application/services/inbound-routing-policy.service.ts +++ b/libs/core/src/sync/application/services/inbound-routing-policy.service.ts @@ -39,6 +39,15 @@ import type { MasterInventorySyncByExternalIdPayloadV1, MasterProductSyncByExternalIdPayloadV1, } from '../../domain/types/master-job-payloads.types'; +import type { RegulatoryStatusReconcilePayloadV1 } from '../../domain/types/invoicing-job-payloads.types'; + +/** + * Page size for the reconcile job a webhook-triggered `invoicing` event + * enqueues. The webhook is a trigger, not the source of truth (#1281, + * mirrors the InPost/webhook philosophy): it only shortens the latency + * until the next scheduled reconcile drains the full non-terminal frontier. + */ +const INVOICING_WEBHOOK_RECONCILE_LIMIT = 50; /** * Local mirror of the order-domain event vocabulary. `OrderFeedEventType` is @@ -152,6 +161,20 @@ export class InboundRoutingPolicyService implements IInboundRoutingPolicyService externalId: event.externalId, } satisfies MarketplaceShipmentSyncByExternalIdPayloadV1, }; + case 'invoicing': + // No by-id job exists (or is needed) — a clearance-status webhook + // (e.g. Infakt relaying a KSeF update) is a trigger, not the source + // of truth. It nudges the existing page-scan reconciler rather than + // inventing a by-id job; the scheduled run still drains the full + // non-terminal frontier regardless. + return { + jobType: 'invoicing.regulatoryStatus.reconcile', + requiredCapability: 'Invoicing', + payload: { + schemaVersion: 1, + limit: INVOICING_WEBHOOK_RECONCILE_LIMIT, + } satisfies RegulatoryStatusReconcilePayloadV1, + }; default: { // Exhaustive — `domain` is a closed union; this guards future additions. const exhaustive: never = event.domain; diff --git a/libs/integrations/infakt/src/index.ts b/libs/integrations/infakt/src/index.ts index cd68b38fa..66441bfa9 100644 --- a/libs/integrations/infakt/src/index.ts +++ b/libs/integrations/infakt/src/index.ts @@ -16,3 +16,8 @@ export type { InfaktInvoice, InfaktClient, InfaktKsefData, InfaktKsefStatus } fr export { InfaktConnectionConfigShapeValidatorAdapter } from './infrastructure/adapters/infakt-connection-config-shape-validator.adapter'; export { InfaktConnectionCredentialsShapeValidatorAdapter } from './infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter'; export { InfaktRetryClassifierAdapter } from './infrastructure/adapters/infakt-retry-classifier.adapter'; + +// Webhook ingress (#1281, ADR-021/ADR-015) — exported so host-side tests can +// register the real adapters. +export { InfaktInboundWebhookDecoderAdapter } from './infrastructure/adapters/infakt-inbound-webhook-decoder.adapter'; +export { InfaktWebhookEventTranslatorAdapter } from './infrastructure/adapters/infakt-webhook-event-translator.adapter'; diff --git a/libs/integrations/infakt/src/infakt-integration.module.ts b/libs/integrations/infakt/src/infakt-integration.module.ts index 2bf31b847..eccbfff08 100644 --- a/libs/integrations/infakt/src/infakt-integration.module.ts +++ b/libs/integrations/infakt/src/infakt-integration.module.ts @@ -6,10 +6,10 @@ * `createNestAdapterModule` directly: the helper imports the * integrations/sync/identifier-mapping modules, builds the `HostServices` * bag from DI, and registers the manifest + factory + the descriptor's - * side-registrations (config/credentials validators, retry classifier). + * side-registrations (config/credentials validators, retry classifier, + * inbound webhook decoder + translator — #1281). * - * Not yet wired into `apps/api/src/plugins.ts` / `apps/worker/src/plugins.ts` - * — that registration + webhook routing is #1281. + * Wired into `apps/api/src/plugins.ts` and `apps/worker/src/plugins.ts`. * * @module libs/integrations/infakt/src */ diff --git a/libs/integrations/infakt/src/infakt-plugin.ts b/libs/integrations/infakt/src/infakt-plugin.ts index 2c0bd7f0a..01dd16a3d 100644 --- a/libs/integrations/infakt/src/infakt-plugin.ts +++ b/libs/integrations/infakt/src/infakt-plugin.ts @@ -27,6 +27,8 @@ import { InfaktAdapterFactory } from './application/infakt-adapter.factory'; import { InfaktConnectionConfigShapeValidatorAdapter } from './infrastructure/adapters/infakt-connection-config-shape-validator.adapter'; import { InfaktConnectionCredentialsShapeValidatorAdapter } from './infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter'; import { InfaktRetryClassifierAdapter } from './infrastructure/adapters/infakt-retry-classifier.adapter'; +import { InfaktInboundWebhookDecoderAdapter } from './infrastructure/adapters/infakt-inbound-webhook-decoder.adapter'; +import { InfaktWebhookEventTranslatorAdapter } from './infrastructure/adapters/infakt-webhook-event-translator.adapter'; /** * Static plugin manifest. Exported for host tooling (capability-matrix, manifest @@ -58,6 +60,20 @@ export function createInfaktPlugin(): AdapterPlugin { new InfaktConnectionCredentialsShapeValidatorAdapter(INFAKT_BRAND), ); host.retryClassifierRegistry.register(INFAKT_ADAPTER_KEY, new InfaktRetryClassifierAdapter()); + + // #1281 / ADR-021 — third-party-native webhook ingress. The decoder + // (provider-keyed) authenticates + decodes Infakt's KSeF-relay webhook + // (incl. the subscription-verification handshake) at the host ingress; + // the translator (adapterKey-keyed) maps the decoded event onto the + // `invoicing` inbound domain downstream. + host.inboundWebhookDecoderRegistry.register( + infaktAdapterManifest.platformType, + new InfaktInboundWebhookDecoderAdapter(), + ); + host.webhookEventTranslatorRegistry.register( + INFAKT_ADAPTER_KEY, + new InfaktWebhookEventTranslatorAdapter(), + ); }, async createCapabilityAdapter( diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts new file mode 100644 index 000000000..4e81bf5a7 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts @@ -0,0 +1,115 @@ +/** + * Infakt Inbound Webhook Decoder Adapter — unit tests + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import { createHmac } from 'crypto'; +import { InfaktInboundWebhookDecoderAdapter } from '../infakt-inbound-webhook-decoder.adapter'; + +const SECRET = 'test-webhook-secret'; + +function sign(body: Buffer, secret: string): string { + return createHmac('sha256', secret).update(body).digest('hex'); +} + +describe('InfaktInboundWebhookDecoderAdapter', () => { + let adapter: InfaktInboundWebhookDecoderAdapter; + + beforeEach(() => { + adapter = new InfaktInboundWebhookDecoderAdapter(); + }); + + describe('detectHandshake', () => { + it('should echo the verification_code when present', () => { + const body = Buffer.from(JSON.stringify({ verification_code: 'abc123' })); + expect(adapter.detectHandshake(body)).toEqual({ verification_code: 'abc123' }); + }); + + it('should return null for a non-handshake payload', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(adapter.detectHandshake(body)).toBeNull(); + }); + }); + + describe('verify', () => { + it('should return ok=true for a valid signature', () => { + const rawBody = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + const result = adapter.verify({ + rawBody, + headers: { 'x-infakt-signature': sign(rawBody, SECRET) }, + secret: SECRET, + }); + expect(result.ok).toBe(true); + expect(result.timestampMs).toBeUndefined(); + }); + + it('should return ok=false for an invalid signature', () => { + const rawBody = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + const result = adapter.verify({ + rawBody, + headers: { 'x-infakt-signature': 'deadbeef' }, + secret: SECRET, + }); + expect(result.ok).toBe(false); + }); + + it('should return ok=false when the signature header is missing', () => { + const rawBody = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + const result = adapter.verify({ rawBody, headers: {}, secret: SECRET }); + expect(result.ok).toBe(false); + }); + + it('should read the signature header case-insensitively', () => { + const rawBody = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + const result = adapter.verify({ + rawBody, + headers: { 'X-Infakt-Signature': sign(rawBody, SECRET) }, + secret: SECRET, + }); + expect(result.ok).toBe(true); + }); + }); + + describe('extractEnvelope', () => { + it('should route a well-formed invoice event', () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: { uuid: 'e-1', name: 'send_to_ksef_success', retry_counter: 0, created_at: '2026-06-30T10:00:00Z' }, + resource: { status: 'success', invoice_uuid: 'inv-1', ksef_number: 'KSeF-1' }, + }), + ); + const result = adapter.extractEnvelope(rawBody); + expect(result).toEqual({ + action: 'route', + envelope: { + eventId: 'e-1', + eventType: 'send_to_ksef_success', + occurredAt: '2026-06-30T10:00:00Z', + objectType: 'invoice', + externalId: 'inv-1', + payload: { status: 'success', invoice_uuid: 'inv-1', ksef_number: 'KSeF-1' }, + }, + }); + }); + + it('should fall back to the event uuid as externalId when invoice_uuid is absent', () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: { uuid: 'e-2', name: 'draft_invoice_created', retry_counter: 0, created_at: '2026-06-30T10:00:00Z' }, + resource: { id: 42 }, + }), + ); + const result = adapter.extractEnvelope(rawBody); + expect(result.action).toBe('route'); + expect(result.action === 'route' && result.envelope.externalId).toBe('e-2'); + }); + + it('should reject a malformed payload', () => { + const rawBody = Buffer.from('not json'); + expect(adapter.extractEnvelope(rawBody)).toEqual({ + action: 'reject', + reason: 'malformed Infakt webhook payload', + }); + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-webhook-event-translator.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-webhook-event-translator.adapter.spec.ts new file mode 100644 index 000000000..35f1c8f45 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-webhook-event-translator.adapter.spec.ts @@ -0,0 +1,52 @@ +/** + * Infakt Webhook Event Translator Adapter — unit tests + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import type { InboundWebhookEvent } from '@openlinker/core/events'; +import { InfaktWebhookEventTranslatorAdapter } from '../infakt-webhook-event-translator.adapter'; + +function event(overrides: Partial = {}): InboundWebhookEvent { + return { + eventId: 'e-1', + provider: 'infakt', + connectionId: 'conn-1', + eventType: 'send_to_ksef_success', + occurredAt: '2026-06-30T10:00:00Z', + receivedAt: '2026-06-30T10:00:01Z', + objectType: 'invoice', + externalId: 'inv-1', + payload: { status: 'success' }, + ...overrides, + }; +} + +describe('InfaktWebhookEventTranslatorAdapter', () => { + let translator: InfaktWebhookEventTranslatorAdapter; + + beforeEach(() => { + translator = new InfaktWebhookEventTranslatorAdapter(); + }); + + it('should translate a send_to_ksef_success event to the invoicing domain', () => { + expect(translator.translate(event())).toEqual({ + domain: 'invoicing', + externalId: 'inv-1', + eventType: 'send_to_ksef_success', + occurredAt: '2026-06-30T10:00:00Z', + payload: { status: 'success' }, + }); + }); + + it('should translate a send_to_ksef_error event to the invoicing domain', () => { + expect(translator.translate(event({ eventType: 'send_to_ksef_error' }))?.domain).toBe('invoicing'); + }); + + it('should return null for a non-KSeF invoice event (dead-letter)', () => { + expect(translator.translate(event({ eventType: 'draft_invoice_created' }))).toBeNull(); + }); + + it('should return null for a non-invoice object type', () => { + expect(translator.translate(event({ objectType: 'order' }))).toBeNull(); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts new file mode 100644 index 000000000..c75e7dec3 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts @@ -0,0 +1,74 @@ +/** + * Infakt Inbound Webhook Decoder Adapter (#1281, ADR-021) + * + * Authenticates + decodes Infakt's third-party-native webhooks at the host + * ingress, keyed by `provider = 'infakt'`. Wraps the confirmed-live + * `InfaktWebhookTranslator` (verify = HMAC-SHA256 hex over the raw body, + * header `X-Infakt-Signature`; no timestamp header, so `verify` returns no + * `timestampMs` and the shared replay-window check is a no-op for this + * provider) rather than duplicating its crypto/parsing logic. + * + * `detectHandshake` covers Infakt's subscription-verification ping + * (`{"verification_code": "..."}` → echo the same body back) — it runs + * before `verify` per the port contract, since the ping predates any signed + * traffic. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + */ +import type { + DecodeResult, + InboundWebhookDecoderPort, + WebhookVerifyResult, +} from '@openlinker/core/integrations'; +import { Logger } from '@openlinker/shared/logging'; +import { InfaktWebhookTranslator } from '../../infrastructure/webhooks/infakt-webhook-translator'; + +const SIGNATURE_HEADER = 'X-Infakt-Signature'; + +export class InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoderPort { + private readonly logger = new Logger(InfaktInboundWebhookDecoderAdapter.name); + /** Secret-independent parsing helpers only — `verify` builds its own instance with the resolved per-connection secret. */ + private readonly parser = new InfaktWebhookTranslator({ secret: '' }, this.logger); + + detectHandshake(rawBody: Buffer): Record | null { + return this.parser.getVerificationEcho(rawBody); + } + + verify(input: { + rawBody: Buffer; + headers: Record; + secret: string; + }): WebhookVerifyResult { + const signature = this.header(input.headers, SIGNATURE_HEADER); + const translator = new InfaktWebhookTranslator({ secret: input.secret }, this.logger); + return { ok: translator.verifySignature(input.rawBody, signature) }; + } + + extractEnvelope(rawBody: Buffer): DecodeResult { + const parsed = this.parser.parse(rawBody); + if (!parsed) { + return { action: 'reject', reason: 'malformed Infakt webhook payload' }; + } + + const externalId = + typeof parsed.resource['invoice_uuid'] === 'string' + ? (parsed.resource['invoice_uuid']) + : parsed.event.uuid; + + return { + action: 'route', + envelope: { + eventId: parsed.event.uuid, + eventType: parsed.event.name, + occurredAt: parsed.event.created_at, + objectType: 'invoice', + externalId, + payload: parsed.resource, + }, + }; + } + + private header(headers: Record, name: string): string | undefined { + return headers[name] ?? headers[name.toLowerCase()]; + } +} diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts new file mode 100644 index 000000000..a1afcf221 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts @@ -0,0 +1,45 @@ +/** + * Infakt Webhook Event Translator Adapter (#1281, ADR-015) + * + * Decodes the neutral envelope produced by `InfaktInboundWebhookDecoderAdapter` + * into a `CanonicalInboundEvent` on the `invoicing` domain. Only the two + * KSeF-clearance event names Infakt confirmed live + * (`send_to_ksef_success` / `send_to_ksef_error`) route through — reused from + * `InfaktWebhookTranslator.toOlDomain` so the allowlist has one source of + * truth with the already-tested POC class. Every other Infakt event + * (`draft_invoice_created`, `invoice_marked_as_paid`, …) is well-formed but + * not yet actionable by OL → `null` (dead-letter), per the translator's + * total-function contract. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + */ +import type { InboundWebhookEvent } from '@openlinker/core/events'; +import type { + CanonicalInboundEvent, + WebhookEventTranslatorPort, +} from '@openlinker/core/integrations'; +import { Logger } from '@openlinker/shared/logging'; +import { InfaktWebhookTranslator } from '../../infrastructure/webhooks/infakt-webhook-translator'; + +export class InfaktWebhookEventTranslatorAdapter implements WebhookEventTranslatorPort { + private readonly translator = new InfaktWebhookTranslator( + { secret: '' }, + new Logger(InfaktWebhookEventTranslatorAdapter.name), + ); + + translate(event: InboundWebhookEvent): CanonicalInboundEvent | null { + if (event.objectType !== 'invoice') { + return null; + } + if (this.translator.toOlDomain(event.eventType) !== 'invoicing') { + return null; + } + return { + domain: 'invoicing', + externalId: event.externalId, + eventType: event.eventType, + occurredAt: event.occurredAt, + payload: event.payload, + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f960f144..c2c1199d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@openlinker/integrations-erli': specifier: workspace:* version: link:../../libs/integrations/erli + '@openlinker/integrations-infakt': + specifier: workspace:* + version: link:../../libs/integrations/infakt '@openlinker/integrations-inpost': specifier: workspace:* version: link:../../libs/integrations/inpost @@ -384,6 +387,9 @@ importers: '@openlinker/integrations-erli': specifier: workspace:* version: link:../../libs/integrations/erli + '@openlinker/integrations-infakt': + specifier: workspace:* + version: link:../../libs/integrations/infakt '@openlinker/integrations-inpost': specifier: workspace:* version: link:../../libs/integrations/inpost From 918228b3d44a017e78ab2b366982af8c1ae86276 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 14:32:35 +0200 Subject: [PATCH 02/27] 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 --- .../adapters/infakt-inbound-webhook-decoder.adapter.ts | 4 ++-- .../infakt-webhook-event-translator.adapter.ts | 6 +++--- .../__tests__/infakt-webhook-translator.spec.ts | 8 ++++++++ .../webhooks/infakt-webhook-translator.ts | 10 ++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts index c75e7dec3..2b2540436 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts @@ -21,14 +21,14 @@ import type { WebhookVerifyResult, } from '@openlinker/core/integrations'; import { Logger } from '@openlinker/shared/logging'; -import { InfaktWebhookTranslator } from '../../infrastructure/webhooks/infakt-webhook-translator'; +import { InfaktWebhookTranslator } from '../webhooks/infakt-webhook-translator'; const SIGNATURE_HEADER = 'X-Infakt-Signature'; export class InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoderPort { private readonly logger = new Logger(InfaktInboundWebhookDecoderAdapter.name); /** Secret-independent parsing helpers only — `verify` builds its own instance with the resolved per-connection secret. */ - private readonly parser = new InfaktWebhookTranslator({ secret: '' }, this.logger); + private readonly parser = InfaktWebhookTranslator.forParsing(this.logger); detectHandshake(rawBody: Buffer): Record | null { return this.parser.getVerificationEcho(rawBody); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts index a1afcf221..1892f4601 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-webhook-event-translator.adapter.ts @@ -19,11 +19,11 @@ import type { WebhookEventTranslatorPort, } from '@openlinker/core/integrations'; import { Logger } from '@openlinker/shared/logging'; -import { InfaktWebhookTranslator } from '../../infrastructure/webhooks/infakt-webhook-translator'; +import { InfaktWebhookTranslator } from '../webhooks/infakt-webhook-translator'; export class InfaktWebhookEventTranslatorAdapter implements WebhookEventTranslatorPort { - private readonly translator = new InfaktWebhookTranslator( - { secret: '' }, + /** Secret-independent parsing helpers only — this adapter never verifies signatures. */ + private readonly translator = InfaktWebhookTranslator.forParsing( new Logger(InfaktWebhookEventTranslatorAdapter.name), ); diff --git a/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts b/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts index 9c4fb0a5c..fd2561a05 100644 --- a/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts @@ -30,6 +30,14 @@ describe('InfaktWebhookTranslator', () => { translator = new InfaktWebhookTranslator({ secret: SECRET }, logger); }); + describe('forParsing', () => { + it('should build a translator usable for parsing without a real secret', () => { + const parsingTranslator = InfaktWebhookTranslator.forParsing(logger); + const body = Buffer.from(JSON.stringify({ verification_code: 'abc123' })); + expect(parsingTranslator.getVerificationEcho(body)).toEqual({ verification_code: 'abc123' }); + }); + }); + describe('verifySignature', () => { it('should return true for a valid signature', () => { const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); diff --git a/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts index 3386f27f4..0cb96b916 100644 --- a/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts +++ b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts @@ -89,6 +89,16 @@ export class InfaktWebhookTranslator { private readonly logger: LoggerPort, ) {} + /** + * Builds a translator for callers that only need the secret-independent + * parsing helpers (`getVerificationEcho`, `parse`, `toOlDomain`, + * `toKsefResource`) — never `verifySignature`, which is always constructed + * separately with the per-connection resolved secret. + */ + static forParsing(logger: LoggerPort): InfaktWebhookTranslator { + return new InfaktWebhookTranslator({ secret: '' }, logger); + } + /** * Returns the verification_code echo body when Infakt sends a verification ping. * The OL webhook controller must respond with this JSON body to activate the webhook. From a185d938ecebd37a3a547ae93952295d4b7c1ce9 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 17:04:17 +0200 Subject: [PATCH 03/27] 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 --- .../src/webhooks/http/webhook.controller.ts | 14 ++- .../infakt/src/domain/types/infakt.types.ts | 10 +- ...kt-inbound-webhook-decoder.adapter.spec.ts | 16 ++- .../infakt-invoicing.adapter.spec.ts | 104 ++++++++++++++++-- .../infakt-inbound-webhook-decoder.adapter.ts | 8 ++ .../adapters/infakt-invoicing.adapter.ts | 104 ++++++++++++++---- 6 files changed, 217 insertions(+), 39 deletions(-) diff --git a/apps/api/src/webhooks/http/webhook.controller.ts b/apps/api/src/webhooks/http/webhook.controller.ts index 15419b73b..2b94b0c1c 100644 --- a/apps/api/src/webhooks/http/webhook.controller.ts +++ b/apps/api/src/webhooks/http/webhook.controller.ts @@ -13,6 +13,7 @@ import { Param, Headers, Req, + Res, HttpCode, HttpStatus, BadRequestException, @@ -20,6 +21,7 @@ import { NotFoundException, PayloadTooLargeException, } from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiHeader } from '@nestjs/swagger'; import { Public } from '../../auth/decorators/public.decorator'; import { WebhookService } from '../application/services/webhook.service'; @@ -57,6 +59,7 @@ export class WebhookController { 'Provider-specific webhook signature. OL-module providers send `sha256=`; third-party providers use their own scheme + header (e.g. InPost `x-inpost-signature`, base64 HMAC). Resolved per-provider by the registered decoder.', required: false, }) + @ApiResponse({ status: 200, description: 'Subscription-verification handshake echoed back' }) @ApiResponse({ status: 202, description: 'Webhook accepted and queued for processing' }) @ApiResponse({ status: 400, description: 'Invalid request payload or malformed data' }) @ApiResponse({ status: 401, description: 'Invalid signature or timestamp out of window' }) @@ -67,6 +70,7 @@ export class WebhookController { @Param('connectionId') connectionId: string, @Headers() headers: Record, @Req() req: RequestWithRawBody, + @Res({ passthrough: true }) res: Response, ): Promise | void> { // No `@Body() WebhookRequestDto` — the body shape is the provider's, not // OL's (ADR-021). The per-provider decoder (resolved in WebhookService) @@ -100,7 +104,15 @@ export class WebhookController { } try { - return await this.webhookService.processWebhook(provider, connectionId, rawBody, headers); + const result = await this.webhookService.processWebhook(provider, connectionId, rawBody, headers); + if (result !== undefined) { + // Handshake echo — verified live against Infakt's own "Zweryfikuj" + // button (2026-07-01): it reports "could not verify" on our default + // 202, but accepts 200. Every other outcome (route/ignore) keeps the + // route's default 202 via @HttpCode above. + res.status(HttpStatus.OK); + } + return result; } catch (error) { // Map domain exceptions to HTTP exceptions if (error instanceof WebhookDecodeException) { diff --git a/libs/integrations/infakt/src/domain/types/infakt.types.ts b/libs/integrations/infakt/src/domain/types/infakt.types.ts index 674b3adb7..54c2e89c6 100644 --- a/libs/integrations/infakt/src/domain/types/infakt.types.ts +++ b/libs/integrations/infakt/src/domain/types/infakt.types.ts @@ -84,16 +84,20 @@ export interface InfaktInvoiceService { group: number | null; } -/** Client (buyer) from GET /clients/{uuid}.json or POST /clients.json */ +/** + * Client (buyer) from GET /clients/{uuid}.json or POST /clients.json. + * Field names verified live against the sandbox response (2026-07-01) — + * `company_name` / `postal_code`, not `name` / `post_code`. + */ export interface InfaktClient { id: number; uuid: string; - name: string; + company_name: string; nip: string | null; email: string | null; city: string | null; street: string | null; - post_code: string | null; + postal_code: string | null; country: string | null; } diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts index 4e81bf5a7..5fb1cff00 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-inbound-webhook-decoder.adapter.spec.ts @@ -95,7 +95,7 @@ describe('InfaktInboundWebhookDecoderAdapter', () => { it('should fall back to the event uuid as externalId when invoice_uuid is absent', () => { const rawBody = Buffer.from( JSON.stringify({ - event: { uuid: 'e-2', name: 'draft_invoice_created', retry_counter: 0, created_at: '2026-06-30T10:00:00Z' }, + event: { uuid: 'e-2', name: 'send_to_ksef_error', retry_counter: 0, created_at: '2026-06-30T10:00:00Z' }, resource: { id: 42 }, }), ); @@ -104,6 +104,20 @@ describe('InfaktInboundWebhookDecoderAdapter', () => { expect(result.action === 'route' && result.envelope.externalId).toBe('e-2'); }); + it('should ignore (not route) an Infakt event OL does not act on', () => { + const rawBody = Buffer.from( + JSON.stringify({ + event: { uuid: 'e-3', name: 'draft_invoice_created', retry_counter: 0, created_at: '2026-06-30T10:00:00Z' }, + resource: { id: 42 }, + }), + ); + const result = adapter.extractEnvelope(rawBody); + expect(result).toEqual({ + action: 'ignore', + reason: 'unhandled Infakt event: draft_invoice_created', + }); + }); + it('should reject a malformed payload', () => { const rawBody = Buffer.from('not json'); expect(adapter.extractEnvelope(rawBody)).toEqual({ diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts index dd5b08c89..effee042b 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -80,6 +80,29 @@ function invoiceFixture(overrides: Partial = {}): InfaktInvoice { }; } +function ksefResponseFixture( + overrides: Partial<{ status: 'pending' | 'sent' | 'success' | 'error'; ksef_number: string | null }> = {}, +): { + request_uuid: string; + invoice_uuid: string; + invoice_kind: string; + ksef_number: string | null; + status: 'pending' | 'sent' | 'success' | 'error'; + status_description: string | null; + timestamps: { request_created_at: string | null; request_finished_at: string | null }; +} { + return { + request_uuid: 'req-uuid-1', + invoice_uuid: 'inv-uuid-1', + invoice_kind: 'vat', + ksef_number: null, + status: 'pending', + status_description: 'Faktura oczekuje na wysłanie do KSeF.', + timestamps: { request_created_at: '2026-07-01T12:00:00Z', request_finished_at: '2026-07-01T12:00:00Z' }, + ...overrides, + }; +} + describe('InfaktInvoicingAdapter', () => { let http: FakeInfaktHttpClient; let logger: jest.Mocked; @@ -107,12 +130,12 @@ describe('InfaktInvoicingAdapter', () => { const existing: InfaktClient = { id: 1, uuid: 'client-existing', - name: 'Acme', + company_name: 'Acme', nip: '1234567890', email: null, city: null, street: null, - post_code: null, + postal_code: null, country: null, }; http.seed>('GET', 'clients.json', { @@ -125,7 +148,9 @@ describe('InfaktInvoicingAdapter', () => { buyer: buyer({ nip: '1234567890' }), }); - expect(result).toEqual({ providerCustomerId: 'client-existing' }); + // The neutral providerCustomerId carries Infakt's NUMERIC client id + // (not the uuid) — invoices.json only accepts client_id. + expect(result).toEqual({ providerCustomerId: '1' }); }); it('should create a new client when no NIP match exists (happy path)', async () => { @@ -136,12 +161,12 @@ describe('InfaktInvoicingAdapter', () => { const created: InfaktClient = { id: 2, uuid: 'client-new', - name: 'Acme', + company_name: 'Acme', nip: '1234567890', email: null, city: 'Warszawa', street: 'Testowa 1', - post_code: '00-001', + postal_code: '00-001', country: 'PL', }; http.seed('POST', 'clients.json', created); @@ -151,26 +176,32 @@ describe('InfaktInvoicingAdapter', () => { buyer: buyer({ nip: '1234567890' }), }); - expect(result).toEqual({ providerCustomerId: 'client-new' }); + expect(result).toEqual({ providerCustomerId: '2' }); + // Field names verified live against the real Infakt v3 sandbox + // (2026-07-01): `name`/`post_code` are silently rejected/ignored. + const createCall = http.calls.find((c) => c.method === 'POST' && c.path === 'clients.json'); + expect(createCall?.body).toMatchObject({ + client: expect.objectContaining({ company_name: 'Acme Sp. z o.o.', postal_code: '00-001' }), + }); }); it('should create a new client without a NIP lookup when the buyer has no tax id', async () => { const created: InfaktClient = { id: 3, uuid: 'client-no-nip', - name: 'Jan Kowalski', + company_name: 'Jan Kowalski', nip: null, email: null, city: 'Warszawa', street: 'Testowa 1', - post_code: '00-001', + postal_code: '00-001', country: 'PL', }; http.seed('POST', 'clients.json', created); const result = await adapter.upsertCustomer({ connectionId: 'conn-1', buyer: buyer() }); - expect(result).toEqual({ providerCustomerId: 'client-no-nip' }); + expect(result).toEqual({ providerCustomerId: '3' }); expect(http.calls.some((c) => c.method === 'GET' && c.path === 'clients.json')).toBe(false); }); @@ -202,14 +233,17 @@ describe('InfaktInvoicingAdapter', () => { http.seed('POST', 'clients.json', { id: 1, uuid: 'client-uuid-1', - name: 'Acme', + company_name: 'Acme', nip: '1234567890', email: null, city: null, street: null, - post_code: null, + postal_code: null, country: null, }); + // issueInvoice now submits to KSeF inline (issuing IS submitting) — + // verified live (2026-07-01) that an Infakt draft never auto-submits. + http.seed('POST', 'invoices/inv-uuid-1/send_to_ksef.json', ksefResponseFixture()); }); it('should issue an invoice and return an InvoiceRecord (happy path)', async () => { @@ -226,6 +260,39 @@ describe('InfaktInvoicingAdapter', () => { expect(record.status).toBe('issued'); expect(record.idempotencyKey).toBe('idem-1'); expect(record.pdfUrl).toBe('https://infakt.pl/inv-uuid-1.pdf'); + // KSeF submission is inline now — the record reflects the send_to_ksef + // response, not the (necessarily stale, pre-submission) invoice payload. + expect(record.regulatoryStatus).toBe('submitted'); + + // Verified live against the real Infakt v3 sandbox (2026-07-01): + // `payment_method: 'transfer'` 422s without a configured bank account, + // and `client_uuid` is ignored — invoices.json needs the numeric + // `client_id`. + const invoiceCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); + expect(invoiceCall?.body).toMatchObject({ + invoice: expect.objectContaining({ payment_method: 'cash', client_id: 1 }), + }); + // issuing IS submitting for Infakt (draft never auto-submits) — verified + // live (2026-07-01). + expect(http.calls.some((c) => c.method === 'POST' && c.path === 'invoices/inv-uuid-1/send_to_ksef.json')).toBe( + true, + ); + }); + + it('should default an empty taxRate to the Polish standard VAT rate (regime-rate fallback)', async () => { + // Core always leaves InvoiceLine.taxRate empty (documented contract: + // "the provider adapter resolves the regime rate"). Verified live + // (2026-07-01): an empty tax_symbol cascades into services.gross / + // value.tax_values rejections too — every real order line hit this. + http.seed('POST', 'invoices.json', invoiceFixture()); + + await adapter.issueInvoice({ ...baseCmd, lines: [{ name: 'Widget', quantity: 1, unitPriceGross: 123, taxRate: '' }] }); + + const invoiceCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); + const services = (invoiceCall?.body as { invoice: { services: Array<{ tax_symbol: string; unit_net_price: string }> } }) + .invoice.services; + expect(services[0].tax_symbol).toBe('23'); + expect(services[0].unit_net_price).toBe('100.00 PLN'); }); it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { @@ -420,12 +487,27 @@ describe('InfaktInvoicingAdapter', () => { it('should issue a correction and return an InvoiceRecord (happy path)', async () => { http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); + // A correction is its own KSeF document — needs its own send_to_ksef + // kick, same as the original (verified live, 2026-07-01). + http.seed('POST', 'invoices/corr-uuid-1/send_to_ksef.json', ksefResponseFixture()); const record = await adapter.issueCorrection(baseCmd); expect(record).toBeInstanceOf(InvoiceRecord); expect(record.providerInvoiceId).toBe('corr-uuid-1'); expect(record.idempotencyKey).toBe('idem-corr-1'); + expect(record.regulatoryStatus).toBe('submitted'); + + // Verified live against the real Infakt v3 sandbox (2026-07-01): + // omitting client_id on a corrective invoice 422s with "client_id + // required" — the original invoice's own client_id must be forwarded. + const invoiceCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); + expect(invoiceCall?.body).toMatchObject({ + invoice: expect.objectContaining({ client_id: 1 }), + }); + expect( + http.calls.some((c) => c.method === 'POST' && c.path === 'invoices/corr-uuid-1/send_to_ksef.json'), + ).toBe(true); }); it('should parse Infakt\'s "amount currency" unit_net_price string for the untouched-line fallback', async () => { diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts index 2b2540436..b8464ebe2 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts @@ -50,6 +50,14 @@ export class InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoder return { action: 'reject', reason: 'malformed Infakt webhook payload' }; } + // Short-circuit here rather than always routing: every Infakt event OL + // doesn't act on (draft_invoice_created, invoice_marked_as_paid, …) would + // otherwise still pay for a full Postgres dedup-insert + Redis publish + // before being dead-lettered two hops later in WebhookToJobHandler. + if (this.parser.toOlDomain(parsed.event.name) === null) { + return { action: 'ignore', reason: `unhandled Infakt event: ${parsed.event.name}` }; + } + const externalId = typeof parsed.resource['invoice_uuid'] === 'string' ? (parsed.resource['invoice_uuid']) diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 21cdfaa0e..221d9fcf6 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -5,11 +5,15 @@ * over the Infakt REST API v3. PL-specific logic (NIP mapping, ksef_data polling, * paragon vs faktura) stays here — never bleeds into libs/core. * - * KSeF model: OL calls `issueInvoice` (creates a draft in Infakt) then - * `getClearanceStatus` reads `ksef_data.status` off the stored invoice UUID. - * Infakt submits to KSeF natively; OL does not build FA(3) XML. This is why the - * adapter implements `RegulatoryStatusReader` (read-only clearance poll), NOT - * `RegulatoryTransmitter` (active KSeF session + submit). + * KSeF model: `issueInvoice`/`issueCorrection` create the draft in Infakt AND + * explicitly trigger `send_to_ksef.json` inline, one atomic step — verified + * live (2026-07-01): an Infakt draft does NOT auto-submit to KSeF on its own, + * so this call is required or the document sits in `draft` forever. Infakt + * still builds the FA(3) XML and owns the KSeF session itself (OL never + * touches FA(3)); `getClearanceStatus` reads `ksef_data.status` for later + * polling. This is why the adapter implements `RegulatoryStatusReader` + * (read-only clearance poll), NOT `RegulatoryTransmitter` (which implies OL + * itself holds the active KSeF session). * * @module libs/integrations/infakt/src/infrastructure/adapters */ @@ -75,6 +79,18 @@ function toInfaktInvoiceType(documentType: string): string { } } +/** + * Poland's standard VAT rate — the "regime rate" the adapter is documented + * (`order-to-issue-invoice-command.mapper.ts`) to resolve when core leaves + * `InvoiceLine.taxRate` empty, which it always does today (core never names + * a tax rate on the order contract). Verified live (2026-07-01): an empty + * `tax_symbol` doesn't just get rejected on its own field — Infakt cascades + * it into `services.gross` / `value.tax_values` errors too, so EVERY line on + * EVERY invoice 422'd before this fallback existed. + */ +const DEFAULT_PL_VAT_SYMBOL = '23'; +const DEFAULT_PL_VAT_RATE = 0.23; + /** Maps neutral taxRate string to Infakt tax_symbol. */ function toInfaktTaxSymbol(taxRate: string): string { // Common neutral→Infakt mapping; adapter owns this PL logic @@ -97,7 +113,7 @@ function toInfaktTaxSymbol(taxRate: string): string { case 'oo': return 'np'; default: - return taxRate; + return taxRate.trim() === '' ? DEFAULT_PL_VAT_SYMBOL : taxRate; } } @@ -142,32 +158,35 @@ export class InfaktInvoicingAdapter if (nip) { const existing = await this.findClientByNip(nip); if (existing) { - this.logger.log(`Infakt client found by NIP ${nip}: ${existing.uuid}`); - return { providerCustomerId: existing.uuid }; + this.logger.log(`Infakt client found by NIP ${nip}: ${existing.id}`); + return { providerCustomerId: String(existing.id) }; } } - // Create new client + // Create new client. Field names verified live against the sandbox + // (2026-07-01): the API wants `company_name` / `postal_code`, not the + // `name` / `post_code` this previously sent — the latter is silently + // rejected/ignored, so first-time client creation always 422'd. const payload = { client: { - name: buyer.name, + company_name: buyer.name, nip: nip ?? undefined, city: buyer.address.city, street: buyer.address.line1, - post_code: buyer.address.postalCode, + postal_code: buyer.address.postalCode, country: buyer.address.countryIso2, }, }; // InfaktApiError carries `failureMode`; propagate as-is (see issueInvoice). const created = await this.http.post('clients.json', payload); - this.logger.log(`Infakt client created: ${created.uuid}`); - return { providerCustomerId: created.uuid }; + this.logger.log(`Infakt client created: ${created.id}`); + return { providerCustomerId: String(created.id) }; } async issueInvoice(cmd: IssueInvoiceCommand): Promise { const { lines, currency, documentType, idempotencyKey, orderId } = cmd; - const clientUuid = await this.resolveClientUuid(cmd); + const clientId = await this.resolveClientId(cmd); const kind = documentType === 'proforma' ? 'proforma' : 'vat'; const services = lines.map((l) => ({ @@ -181,8 +200,15 @@ export class InfaktInvoicingAdapter const payload = { invoice: { kind, - payment_method: 'transfer', - client_uuid: clientUuid, + // 'cash' is the sandbox/production-safe default confirmed live + // (2026-06-30 POC) — 'transfer' is rejected unless the seller has a + // bank account configured in Infakt (`bank_account`/`bank_name` + // required), which OL has no way to know or configure per-connection. + payment_method: 'cash', + // Infakt's invoices.json wants the NUMERIC client id, not the client + // uuid — verified live (2026-07-01): `client_uuid` is silently + // ignored and the request 422s with "client_id required". + client_id: clientId, services, ...(idempotencyKey ? { external_id: idempotencyKey } : {}), }, @@ -195,7 +221,15 @@ export class InfaktInvoicingAdapter this.logger.log(`Infakt invoice created: ${invoice.uuid} (${invoice.number ?? 'draft'})`); - const ksefStatus = invoice.ksef_data?.status ?? null; + // Issuing does NOT submit to KSeF on its own — verified live (2026-07-01): + // an Infakt invoice sits in `draft` (KSeF-untouched) forever unless + // send_to_ksef.json is called explicitly. Mirrors how KSeF's own + // `issueInvoice` submits inline (build → session → submit, one atomic + // step) and how Subiekt "transmits to KSeF natively at issuance" — for + // Infakt that native transmission requires this explicit kick, so it + // belongs in the same place: issuing IS submitting. + const ksefResult = await this.sendToKsef(invoice.uuid); + const now = new Date(); const record = new InvoiceRecord( randomUUID(), @@ -206,8 +240,8 @@ export class InfaktInvoicingAdapter 'issued', invoice.uuid, invoice.number ?? null, - toRegulatoryStatus(ksefStatus), - invoice.ksef_data?.ksef_number ?? null, + toRegulatoryStatus(ksefResult.status), + ksefResult.ksef_number, idempotencyKey ?? null, invoice.pdf_url ?? null, now, @@ -339,6 +373,11 @@ export class InfaktInvoicingAdapter invoice: { kind: 'corrective', payment_method: 'cash', + // Required by Infakt on every invoice, corrective included — verified + // live (2026-07-01): omitting it 422s with "client_id required". The + // original invoice already carries the numeric id, so no extra + // upsertCustomer round-trip is needed for a correction. + client_id: original.client_id, corrected_invoice_number: original.number, corrected_invoice_date: original.invoice_date ?? new Date().toISOString().slice(0, 10), correction_reason_symbol: 'other', @@ -352,6 +391,11 @@ export class InfaktInvoicingAdapter const invoice = await this.http.post('invoices.json', payload); this.logger.log(`Infakt correction created: ${invoice.uuid} (${invoice.number ?? 'draft'})`); + + // A correction is its own KSeF document (KOR) — it needs the same explicit + // submission kick as the original (see issueInvoice). + const ksefResult = await this.sendToKsef(invoice.uuid); + const now = new Date(); return new InvoiceRecord( randomUUID(), @@ -362,8 +406,8 @@ export class InfaktInvoicingAdapter 'issued', invoice.uuid, invoice.number ?? null, - 'not-applicable', - null, + toRegulatoryStatus(ksefResult.status), + ksefResult.ksef_number, idempotencyKey ?? null, invoice.pdf_url ?? null, now, @@ -374,6 +418,9 @@ export class InfaktInvoicingAdapter } // --- Infakt-specific: trigger KSeF submission --- + // Called inline by issueInvoice/issueCorrection (issuing IS submitting for + // this provider). Left public rather than private so an operator-facing + // manual re-submit can reuse it later without adding a second code path. async sendToKsef(invoiceUuid: string): Promise { return this.http.post( @@ -384,9 +431,9 @@ export class InfaktInvoicingAdapter // --- helpers --- - private async resolveClientUuid(cmd: IssueInvoiceCommand): Promise { + private async resolveClientId(cmd: IssueInvoiceCommand): Promise { const result = await this.upsertCustomer({ connectionId: cmd.connectionId, buyer: cmd.buyer }); - return result.providerCustomerId; + return Number(result.providerCustomerId); } private async findClientByNip(nip: string): Promise { @@ -399,4 +446,15 @@ export class InfaktInvoicingAdapter return null; } } + + private taxRateNumeric(taxRate: string): number { + // Must stay consistent with toInfaktTaxSymbol's empty-string fallback — + // a mismatched net/gross split for the declared tax_symbol is itself + // rejected by Infakt as an invalid `value.tax_values`. + if (taxRate.trim() === '') return DEFAULT_PL_VAT_RATE; + const n = parseFloat(taxRate); + if (!isNaN(n) && n > 1) return n / 100; + if (!isNaN(n)) return n; + return 0; + } } From 89f220c658ec83b28d686a6d794b0efddab46118 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 18:40:57 +0200 Subject: [PATCH 04/27] 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 Signed-off-by: norbert-kulus-blockydevs --- .../infakt-webhook-translator.spec.ts | 21 +++++++++++++++++++ .../webhooks/infakt-webhook-translator.ts | 17 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts b/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts index fd2561a05..9a86f07e5 100644 --- a/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts @@ -75,6 +75,27 @@ describe('InfaktWebhookTranslator', () => { const body = Buffer.from('not json'); expect(translator.getVerificationEcho(body)).toBeNull(); }); + + it('should return null when a signed event carries a verification_code field', () => { + const body = Buffer.from( + JSON.stringify({ + event: { uuid: 'e-1', name: 'send_to_ksef_success' }, + resource: { verification_code: 'not-a-handshake' }, + }), + ); + expect(translator.getVerificationEcho(body)).toBeNull(); + }); + + it('should return null when verification_code exceeds the length cap', () => { + const body = Buffer.from(JSON.stringify({ verification_code: 'a'.repeat(257) })); + expect(translator.getVerificationEcho(body)).toBeNull(); + }); + + it('should echo a verification_code at exactly the length cap', () => { + const code = 'a'.repeat(256); + const body = Buffer.from(JSON.stringify({ verification_code: code })); + expect(translator.getVerificationEcho(body)).toEqual({ verification_code: code }); + }); }); describe('parse', () => { diff --git a/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts index 0cb96b916..7f16b01c7 100644 --- a/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts +++ b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts @@ -84,6 +84,9 @@ export interface InfaktWebhookTranslatorConfig { } export class InfaktWebhookTranslator { + /** Handshake pings send a short random token; caps the pre-verification echo (see `getVerificationEcho`). */ + private static readonly MAX_VERIFICATION_CODE_LENGTH = 256; + constructor( private readonly config: InfaktWebhookTranslatorConfig, private readonly logger: LoggerPort, @@ -103,6 +106,14 @@ export class InfaktWebhookTranslator { * Returns the verification_code echo body when Infakt sends a verification ping. * The OL webhook controller must respond with this JSON body to activate the webhook. * Returns null if the payload is not a verification ping. + * + * Gated on the absence of the `event` envelope: every signed delivery is + * `{ event, resource }` (see `parse`), while the handshake ping is the bare + * `{ verification_code }` shape. Without this guard, a signed event that + * happened to carry a string `verification_code` field would be + * mis-short-circuited here and never routed. `verification_code` is also + * length-capped — the handshake echo is returned pre-signature-verification, + * so an oversized value is rejected rather than reflected back verbatim. */ getVerificationEcho(rawBody: Buffer): { verification_code: string } | null { try { @@ -110,10 +121,14 @@ export class InfaktWebhookTranslator { if ( typeof parsed === 'object' && parsed !== null && + !('event' in parsed) && 'verification_code' in parsed && typeof (parsed as Record)['verification_code'] === 'string' ) { - return { verification_code: (parsed as { verification_code: string }).verification_code }; + const code = (parsed as { verification_code: string }).verification_code; + if (code.length <= InfaktWebhookTranslator.MAX_VERIFICATION_CODE_LENGTH) { + return { verification_code: code }; + } } } catch { // not JSON From c0b09262131b614e58af2f1f47278806b04dbf5f Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 22:45:56 +0200 Subject: [PATCH 05/27] 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 Signed-off-by: norbert-kulus-blockydevs --- .../infakt-invoicing.adapter.spec.ts | 2 ++ .../adapters/infakt-invoicing.adapter.ts | 21 ++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts index effee042b..82f92c549 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -516,6 +516,7 @@ describe('InfaktInvoicingAdapter', () => { // the "before" row and the fallback "after" row go through this path. http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); + http.seed('POST', 'invoices/corr-uuid-1/send_to_ksef.json', ksefResponseFixture()); await adapter.issueCorrection(baseCmd); @@ -534,6 +535,7 @@ describe('InfaktInvoicingAdapter', () => { it('should convert a price-changing correction line from gross to net (#1292 review)', async () => { http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); + http.seed('POST', 'invoices/corr-uuid-1/send_to_ksef.json', ksefResponseFixture()); await adapter.issueCorrection({ ...baseCmd, diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 221d9fcf6..465ec601f 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -117,8 +117,16 @@ function toInfaktTaxSymbol(taxRate: string): string { } } -/** Parses a tax-rate string (neutral `'23'`/`'0.23'` or Infakt `tax_symbol` `'zw'`/`'np'`) to a decimal fraction. */ +/** + * Parses a tax-rate string (neutral `'23'`/`'0.23'` or Infakt `tax_symbol` + * `'zw'`/`'np'`) to a decimal fraction. + * + * Must stay consistent with `toInfaktTaxSymbol`'s empty-string fallback — a + * mismatched net/gross split for the declared tax_symbol is itself rejected + * by Infakt as an invalid `value.tax_values`. + */ function taxRateNumeric(taxRate: string): number { + if (taxRate.trim() === '') return DEFAULT_PL_VAT_RATE; const n = parseFloat(taxRate); if (!isNaN(n) && n > 1) return n / 100; if (!isNaN(n)) return n; @@ -446,15 +454,4 @@ export class InfaktInvoicingAdapter return null; } } - - private taxRateNumeric(taxRate: string): number { - // Must stay consistent with toInfaktTaxSymbol's empty-string fallback — - // a mismatched net/gross split for the declared tax_symbol is itself - // rejected by Infakt as an invalid `value.tax_values`. - if (taxRate.trim() === '') return DEFAULT_PL_VAT_RATE; - const n = parseFloat(taxRate); - if (!isNaN(n) && n > 1) return n / 100; - if (!isNaN(n)) return n; - return 0; - } } From 17e4279bf85760b8b48db77d5bf5426845d59e85 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 22:49:43 +0200 Subject: [PATCH 06/27] 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 Signed-off-by: norbert-kulus-blockydevs --- .../infakt-invoicing.adapter.spec.ts | 34 +++++++++++++++++++ .../adapters/infakt-invoicing.adapter.ts | 19 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts index 82f92c549..c435e9219 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -320,6 +320,23 @@ describe('InfaktInvoicingAdapter', () => { statusCode: 500, }); }); + + it('should propagate a failure from sendToKsef after the draft was already created (error path, #1293 review)', async () => { + // The draft succeeds but the explicit KSeF submission kick fails — the + // draft is left orphaned in Infakt (retry-safety assumption documented + // inline on the sendToKsef call site). + http.seed('POST', 'invoices.json', invoiceFixture()); + http.seedError( + 'POST', + 'invoices/inv-uuid-1/send_to_ksef.json', + new InfaktApiError('Infakt server error', 500, { error: 'internal' }), + ); + + await expect(adapter.issueInvoice(baseCmd)).rejects.toMatchObject({ + failureMode: 'in-doubt', + statusCode: 500, + }); + }); }); describe('getInvoice', () => { @@ -581,5 +598,22 @@ describe('InfaktInvoicingAdapter', () => { await expect(adapter.issueCorrection(baseCmd)).rejects.toMatchObject({ statusCode: 404 }); }); + + it('should propagate a failure from sendToKsef after the correction draft was already created (error path, #1293 review)', async () => { + // Same orphaned-draft risk as issueInvoice's equivalent case — the + // corrective draft succeeds but the explicit KSeF submission kick fails. + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); + http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); + http.seedError( + 'POST', + 'invoices/corr-uuid-1/send_to_ksef.json', + new InfaktApiError('Infakt server error', 500, { error: 'internal' }), + ); + + await expect(adapter.issueCorrection(baseCmd)).rejects.toMatchObject({ + failureMode: 'in-doubt', + statusCode: 500, + }); + }); }); }); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 465ec601f..69b33d467 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -236,6 +236,18 @@ export class InfaktInvoicingAdapter // step) and how Subiekt "transmits to KSeF natively at issuance" — for // Infakt that native transmission requires this explicit kick, so it // belongs in the same place: issuing IS submitting. + // + // Retry-safety assumption (unverified — #1293 review): if this call + // throws (network/API error), the draft above was already created and + // this whole method rejects, so core treats issuance as failed. A caller + // retry re-invokes issueInvoice, which re-POSTs invoices.json with the + // SAME external_id (idempotencyKey). We rely on Infakt returning/reusing + // the same invoice uuid for a repeat external_id rather than creating a + // duplicate draft — that would make this second sendToKsef call a safe + // re-attempt on the same document. This dedup behaviour has not been + // confirmed against the live API; if Infakt instead creates a new draft + // per POST, a failed sendToKsef leaves an orphaned un-submitted document + // on every retry. const ksefResult = await this.sendToKsef(invoice.uuid); const now = new Date(); @@ -402,6 +414,13 @@ export class InfaktInvoicingAdapter // A correction is its own KSeF document (KOR) — it needs the same explicit // submission kick as the original (see issueInvoice). + // + // Same retry-safety assumption as issueInvoice (unverified — #1293 + // review): a retry re-calls issueCorrection, which re-POSTs + // invoices.json with the same external_id; we rely on Infakt + // returning/reusing the same correction uuid rather than creating a + // second corrective draft, which would make this sendToKsef call a safe + // re-attempt on the same document. const ksefResult = await this.sendToKsef(invoice.uuid); const now = new Date(); From 4017f18260a5961dc40cbcac8b723282aba674c2 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 23:20:33 +0200 Subject: [PATCH 07/27] 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 Signed-off-by: norbert-kulus-blockydevs --- libs/integrations/infakt/src/infakt-plugin.ts | 2 + .../infakt-connection-tester.adapter.spec.ts | 105 ++++++++++++++++++ .../infakt-connection-tester.adapter.ts | 85 ++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-tester.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-tester.adapter.ts diff --git a/libs/integrations/infakt/src/infakt-plugin.ts b/libs/integrations/infakt/src/infakt-plugin.ts index 01dd16a3d..0cc1036a8 100644 --- a/libs/integrations/infakt/src/infakt-plugin.ts +++ b/libs/integrations/infakt/src/infakt-plugin.ts @@ -29,6 +29,7 @@ import { InfaktConnectionCredentialsShapeValidatorAdapter } from './infrastructu import { InfaktRetryClassifierAdapter } from './infrastructure/adapters/infakt-retry-classifier.adapter'; import { InfaktInboundWebhookDecoderAdapter } from './infrastructure/adapters/infakt-inbound-webhook-decoder.adapter'; import { InfaktWebhookEventTranslatorAdapter } from './infrastructure/adapters/infakt-webhook-event-translator.adapter'; +import { InfaktConnectionTesterAdapter } from './infrastructure/adapters/infakt-connection-tester.adapter'; /** * Static plugin manifest. Exported for host tooling (capability-matrix, manifest @@ -60,6 +61,7 @@ export function createInfaktPlugin(): AdapterPlugin { new InfaktConnectionCredentialsShapeValidatorAdapter(INFAKT_BRAND), ); host.retryClassifierRegistry.register(INFAKT_ADAPTER_KEY, new InfaktRetryClassifierAdapter()); + host.connectionTesterRegistry.register(INFAKT_ADAPTER_KEY, new InfaktConnectionTesterAdapter()); // #1281 / ADR-021 — third-party-native webhook ingress. The decoder // (provider-keyed) authenticates + decodes Infakt's KSeF-relay webhook diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-tester.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-tester.adapter.spec.ts new file mode 100644 index 000000000..1516a92f4 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-tester.adapter.spec.ts @@ -0,0 +1,105 @@ +/** + * Infakt Connection Tester — unit tests + * + * Stubs `global.fetch` to verify the probe maps a 2xx to success, a 401 to a + * clear auth failure, and a transport error to a failure result — never + * throwing (the tester always returns a `ConnectionTestResult`). + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import type { CredentialsResolverPort } from '@openlinker/core/integrations'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import { InfaktConnectionTesterAdapter } from '../infakt-connection-tester.adapter'; + +function connection(overrides: Partial = {}): Connection { + return { + id: 'conn-1', + platformType: 'infakt', + name: 'Infakt', + status: 'active', + config: {}, + credentialsRef: 'ref-1', + enabledCapabilities: [], + adapterKey: 'infakt.accounting.v1', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +const resolver: CredentialsResolverPort = { get: jest.fn().mockResolvedValue({ apiKey: 'k-123' }) }; + +function fakeResponse(ok: boolean, status: number): Response { + return { + ok, + status, + text: (): Promise => Promise.resolve(ok ? '{"entities":[]}' : '{"error":"unauthorized"}'), + } as unknown as Response; +} + +const originalFetch = global.fetch; + +describe('InfaktConnectionTesterAdapter', () => { + let tester: InfaktConnectionTesterAdapter; + let fetchMock: jest.Mock; + + beforeEach(() => { + tester = new InfaktConnectionTesterAdapter(); + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should return success with the probe status on a 2xx response', async () => { + fetchMock.mockResolvedValue(fakeResponse(true, 200)); + + const result = await tester.test(connection(), resolver); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + expect(result.message).toContain('credentials accepted'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('should return a failure with status 401 when the key is rejected', async () => { + fetchMock.mockResolvedValue(fakeResponse(false, 401)); + + const result = await tester.test(connection(), resolver); + + expect(result.success).toBe(false); + expect(result.status).toBe(401); + }); + + it('should return a failure (not throw) on a transport error, collapsing the raw cause', async () => { + fetchMock.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.1:443 /secret-path')); + + const result = await tester.test(connection(), resolver); + + expect(result.success).toBe(false); + expect(result.status).toBeUndefined(); + expect(result.message).toBe('Infakt probe failed'); + expect(result.message).not.toContain('ECONNREFUSED'); + expect(result.message).not.toContain('secret-path'); + }); + + it('should never surface InfaktApiError.responseBody in the result message', async () => { + fetchMock.mockResolvedValue(fakeResponse(false, 422)); + + const result = await tester.test(connection(), resolver); + + expect(result.success).toBe(false); + expect(result.status).toBe(422); + expect(result.message).not.toContain('unauthorized'); + }); + + it('should return a failure (not throw) when the connection has no stored credentials', async () => { + const result = await tester.test(connection({ credentialsRef: undefined }), resolver); + + expect(result.success).toBe(false); + expect(result.message.length).toBeGreaterThan(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-tester.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-tester.adapter.ts new file mode 100644 index 000000000..e40396a14 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-tester.adapter.ts @@ -0,0 +1,85 @@ +/** + * Infakt Connection Tester + * + * Probes a live Infakt connection with one cheap authenticated GET so OL Admin + * can show the connection Active (or a clear failure) right after the operator + * pastes their API key. Resolves credentials directly and builds a bare + * `InfaktHttpClient` (mirrors the `SubiektConnectionTesterAdapter` precedent — + * Infakt's factory only exposes `createInvoicingAdapter`, not a standalone + * HTTP-client construction seam) and maps the outcome to the neutral + * `ConnectionTestResult`. Registered against `ConnectionTesterRegistryService` + * at `infakt.accounting.v1`. + * + * The probe path ({@link INFAKT_CONNECTION_PROBE_PATH}) is `GET /clients.json` + * with `limit=1` — a real, already-used Infakt v3 endpoint (see + * `InfaktInvoicingAdapter.findClientByNip`), cheap, side-effect-free, and + * requires a valid API key so a 2xx confirms both reachability and credential + * validity, same posture as Erli's `GET /me`. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + * @see {@link ConnectionTesterPort} + */ +import type { + ConnectionTesterPort, + ConnectionTestResult, + CredentialsResolverPort, +} from '@openlinker/core/integrations'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import { Logger } from '@openlinker/shared/logging'; +import { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from '../http/infakt-http-client'; +import { InfaktApiError } from '../../domain/exceptions/infakt-api.error'; +import type { InfaktCredentials, InfaktConnectionConfig } from '../../domain/types/infakt-connection.types'; + +const INFAKT_CONNECTION_PROBE_PATH = 'clients.json'; + +export class InfaktConnectionTesterAdapter implements ConnectionTesterPort { + private readonly logger = new Logger(InfaktConnectionTesterAdapter.name); + + async test( + connection: Connection, + credentialsResolver: CredentialsResolverPort, + ): Promise { + const startedAt = Date.now(); + try { + if (!connection.credentialsRef) { + return { + success: false, + message: 'Connection has no stored credentials', + latencyMs: Date.now() - startedAt, + }; + } + + const credentials = await credentialsResolver.get(connection.credentialsRef); + const config = (connection.config ?? {}) as InfaktConnectionConfig; + const client = new InfaktHttpClient( + { apiKey: credentials.apiKey, baseUrl: config.baseUrl ?? INFAKT_DEFAULT_BASE_URL }, + this.logger, + ); + + await client.get(INFAKT_CONNECTION_PROBE_PATH, { limit: '1' }); + + return { + success: true, + status: 200, + // GET /clients.json requires auth, so a 2xx confirms both reachability + // and a valid credential. + message: 'Connection reachable and credentials accepted', + latencyMs: Date.now() - startedAt, + }; + } catch (error) { + return this.toFailure(error, Date.now() - startedAt); + } + } + + private toFailure(error: unknown, latencyMs: number): ConnectionTestResult { + // InfaktApiError.message is bounded and bearer-safe; responseBody is a + // SEPARATE field that may echo back submitted data and must never reach + // the operator-facing result. + if (error instanceof InfaktApiError) { + return { success: false, status: error.statusCode, message: error.message, latencyMs }; + } + // Anything else (raw fetch/undici error, credential-resolution failure) + // collapses to a fixed string — never let an internal detail leak. + return { success: false, status: undefined, message: 'Infakt probe failed', latencyMs }; + } +} From 651cac2803dd59b849230113a48737566e1fe0d6 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Thu, 2 Jul 2026 00:00:15 +0200 Subject: [PATCH 08/27] fix(infakt): send invoice amounts as plain-integer groszy, not decimal strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: norbert-kulus-blockydevs --- .../infakt/src/domain/types/infakt.types.ts | 27 +++++++----- .../infakt-invoicing.adapter.spec.ts | 44 ++++++++++--------- .../adapters/infakt-invoicing.adapter.ts | 44 +++++++++++-------- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/libs/integrations/infakt/src/domain/types/infakt.types.ts b/libs/integrations/infakt/src/domain/types/infakt.types.ts index 54c2e89c6..3a9f39f30 100644 --- a/libs/integrations/infakt/src/domain/types/infakt.types.ts +++ b/libs/integrations/infakt/src/domain/types/infakt.types.ts @@ -47,11 +47,18 @@ export interface InfaktInvoice { number: string | null; kind: InfaktInvoiceKind; status: string; - // Infakt returns monetary fields as "amount currency" strings (e.g. "123.00 PLN"), - // never plain numbers — confirmed against the v3 vat_invoice schema (#1292 review). - gross_price: string; - net_price: string; - tax_price: string; + // Infakt's public API represents every monetary field as a PLAIN INTEGER + // count of groszy (1 PLN = 100 grosze) — confirmed both live against the + // real sandbox (a 349.00 PLN order landed as gross_price=348, i.e. 3.48 PLN, + // when this adapter previously sent "amount currency" decimal strings) and + // against the official MCP-exposed API schema (`unit_net_price`/`net_price`/ + // `gross_price` are `integer`, described as "w groszach"). The earlier + // "amount currency" string assumption (#1292 review) was wrong and caused + // every issued invoice to understate its legal/KSeF amount ~100x (#1293 + // review, live E2E finding). + gross_price: number; + net_price: number; + tax_price: number; payment_method: string; invoice_date: string | null; sale_date: string | null; @@ -75,11 +82,11 @@ export interface InfaktInvoiceService { tax_symbol: string; quantity: number; unit: string | null; - // Same "amount currency" string format as InfaktInvoice's top-level totals. - unit_net_price: string; - net_price: string; - tax_price: string; - gross_price: string; + // Same plain-integer-groszy format as InfaktInvoice's top-level totals. + unit_net_price: number; + net_price: number; + tax_price: number; + gross_price: number; correction: boolean | null; group: number | null; } diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts index c435e9219..f27c9df4c 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -45,9 +45,9 @@ function invoiceFixture(overrides: Partial = {}): InfaktInvoice { number: 'FV/1/2026', kind: 'vat', status: 'sent', - gross_price: '123.00 PLN', - net_price: '100.00 PLN', - tax_price: '23.00 PLN', + gross_price: 12300, + net_price: 10000, + tax_price: 2300, payment_method: 'transfer', invoice_date: '2026-07-01', sale_date: '2026-07-01', @@ -66,10 +66,10 @@ function invoiceFixture(overrides: Partial = {}): InfaktInvoice { tax_symbol: '23', quantity: 1, unit: 'szt.', - unit_net_price: '100.00 PLN', - net_price: '100.00 PLN', - tax_price: '23.00 PLN', - gross_price: '123.00 PLN', + unit_net_price: 10000, + net_price: 10000, + tax_price: 2300, + gross_price: 12300, correction: null, group: null, }, @@ -289,10 +289,10 @@ describe('InfaktInvoicingAdapter', () => { await adapter.issueInvoice({ ...baseCmd, lines: [{ name: 'Widget', quantity: 1, unitPriceGross: 123, taxRate: '' }] }); const invoiceCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); - const services = (invoiceCall?.body as { invoice: { services: Array<{ tax_symbol: string; unit_net_price: string }> } }) + const services = (invoiceCall?.body as { invoice: { services: Array<{ tax_symbol: string; unit_net_price: number }> } }) .invoice.services; expect(services[0].tax_symbol).toBe('23'); - expect(services[0].unit_net_price).toBe('100.00 PLN'); + expect(services[0].unit_net_price).toBe(10000); }); it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { @@ -527,10 +527,11 @@ describe('InfaktInvoicingAdapter', () => { ).toBe(true); }); - it('should parse Infakt\'s "amount currency" unit_net_price string for the untouched-line fallback', async () => { - // Infakt returns unit_net_price as "100.00 PLN", never a plain number - // (#1292 review); baseCmd's line carries no newUnitPriceGross, so both - // the "before" row and the fallback "after" row go through this path. + it('should carry the original unit_net_price (plain integer groszy) through for the untouched-line fallback', async () => { + // Infakt's wire format is a plain integer count of groszy (#1293 review + // finding — the earlier "amount currency" string assumption understated + // every invoice ~100x); baseCmd's line carries no newUnitPriceGross, so + // both the "before" row and the fallback "after" row go through this path. http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); http.seed('POST', 'invoices/corr-uuid-1/send_to_ksef.json', ksefResponseFixture()); @@ -539,17 +540,17 @@ describe('InfaktInvoicingAdapter', () => { const postCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); const body = postCall?.body as { - invoice: { services: { unit_net_price: string; correction: boolean }[] }; + invoice: { services: { unit_net_price: number; correction: boolean }[] }; }; expect(body.invoice.services.find((s) => s.correction === false)?.unit_net_price).toBe( - '100.00 PLN', + 10000, ); expect(body.invoice.services.find((s) => s.correction === true)?.unit_net_price).toBe( - '100.00 PLN', + 10000, ); }); - it('should convert a price-changing correction line from gross to net (#1292 review)', async () => { + it('should convert a price-changing correction line from gross to net, in groszy (#1292 review)', async () => { http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); http.seed('POST', 'invoices/corr-uuid-1/send_to_ksef.json', ksefResponseFixture()); @@ -561,12 +562,13 @@ describe('InfaktInvoicingAdapter', () => { const postCall = http.calls.find((c) => c.method === 'POST' && c.path === 'invoices.json'); const body = postCall?.body as { - invoice: { services: { unit_net_price: string; correction: boolean }[] }; + invoice: { services: { unit_net_price: number; correction: boolean }[] }; }; const correctedRow = body.invoice.services.find((s) => s.correction === true); - // 61.5 gross / 1.23 (tax_symbol '23') = 50.00 net — was previously written - // straight through as "61.50 PLN", overstating the net price. - expect(correctedRow?.unit_net_price).toBe('50.00 PLN'); + // 61.5 gross / 1.23 (tax_symbol '23') = 50.00 net PLN = 5000 groszy — + // was previously written straight through as "61.50 PLN" (61500 groszy + // if naively converted), overstating the net price. + expect(correctedRow?.unit_net_price).toBe(5000); }); it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 69b33d467..233ec7cb6 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -133,15 +133,26 @@ function taxRateNumeric(taxRate: string): number { return 0; } -/** Converts a buyer-paid gross unit price to Infakt's net unit price for the given tax rate. */ +/** Converts a buyer-paid gross unit price (PLN) to Infakt's net unit price (PLN) for the given tax rate. */ function grossToNet(unitPriceGross: number, taxRate: string): number { return unitPriceGross / (1 + taxRateNumeric(taxRate)); } -/** Parses Infakt's "amount currency" monetary string (e.g. "123.00 PLN") to a number. */ -function parseInfaktAmount(value: string): number { - const n = parseFloat(value); - return isNaN(n) ? 0 : n; +/** + * Converts a PLN amount to Infakt's wire format: a plain integer count of + * groszy (1 PLN = 100 groszy). Confirmed both live against the real sandbox + * and against the official API schema — `unit_net_price`/`net_price`/ + * `gross_price` are `integer`, documented "w groszach". Sending a decimal + * "amount currency" string here (the previous behaviour) understated every + * invoice's legal/KSeF amount ~100x (#1293 review). + */ +function toGroszy(amountPln: number): number { + return Math.round(amountPln * 100); +} + +/** Converts an Infakt wire amount (plain integer groszy) back to a PLN decimal for arithmetic. */ +function fromGroszy(amountGroszy: number): number { + return amountGroszy / 100; } export class InfaktInvoicingAdapter @@ -193,7 +204,7 @@ export class InfaktInvoicingAdapter } async issueInvoice(cmd: IssueInvoiceCommand): Promise { - const { lines, currency, documentType, idempotencyKey, orderId } = cmd; + const { lines, documentType, idempotencyKey, orderId } = cmd; const clientId = await this.resolveClientId(cmd); const kind = documentType === 'proforma' ? 'proforma' : 'vat'; @@ -202,7 +213,8 @@ export class InfaktInvoicingAdapter tax_symbol: toInfaktTaxSymbol(l.taxRate), quantity: l.quantity, unit: 'szt.', - unit_net_price: `${grossToNet(l.unitPriceGross, l.taxRate).toFixed(2)} ${currency ?? 'PLN'}`, + // Plain integer groszy, NOT an "amount currency" string — see toGroszy. + unit_net_price: toGroszy(grossToNet(l.unitPriceGross, l.taxRate)), })); const payload = { @@ -346,25 +358,19 @@ export class InfaktInvoicingAdapter { invoice_type: 'vat' }, ); - // Infakt's InfaktInvoice type carries no currency field (accounts are - // single-currency in practice); PLN mirrors issueInvoice's own default - // and is the only value Infakt sandbox/production has ever returned here. - const currency = 'PLN'; - // Build correction services: original row (correction: false) + corrected row (correction: true) const correctionServices = original.services.flatMap((svc, idx) => { const corrLine = lines.find((l) => l.originalLineNumber === idx + 1); const corrQty = corrLine?.newQuantity ?? svc.quantity; - // Infakt returns unit_net_price as an "amount currency" string (e.g. - // "100.00 PLN"), never a plain number — confirmed against the v3 - // schema (#1292 review) — so it must be parsed before arithmetic. - const originalNet = parseInfaktAmount(svc.unit_net_price); + // svc.unit_net_price is a plain integer groszy (Infakt wire format — + // see toGroszy/fromGroszy) — convert to a PLN decimal before arithmetic. + const originalNet = fromGroszy(svc.unit_net_price); // newUnitPriceGross is gross (IssueCorrectionCommand contract); Infakt's // unit_net_price is net — convert using the ORIGINAL line's tax_symbol, // same as issueInvoice's gross→net conversion (#1292 review). const corrPrice = corrLine?.newUnitPriceGross - ? `${grossToNet(corrLine.newUnitPriceGross, svc.tax_symbol).toFixed(2)} ${currency}` - : `${originalNet.toFixed(2)} ${currency}`; + ? toGroszy(grossToNet(corrLine.newUnitPriceGross, svc.tax_symbol)) + : toGroszy(originalNet); return [ // Original "before" row { @@ -372,7 +378,7 @@ export class InfaktInvoicingAdapter tax_symbol: svc.tax_symbol, quantity: svc.quantity, unit: svc.unit ?? 'szt.', - unit_net_price: `${originalNet.toFixed(2)} ${currency}`, + unit_net_price: toGroszy(originalNet), group: idx + 1, correction: false, }, From 0bca26e829ac399ed67e9c19bdc4188de0c7647e Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Thu, 2 Jul 2026 00:14:44 +0200 Subject: [PATCH 09/27] fix(infakt): polish remaining review suggestions on PR #1293 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/webhooks/http/webhook.controller.ts | 4 ++++ .../adapters/infakt-inbound-webhook-decoder.adapter.ts | 2 +- .../src/infrastructure/adapters/infakt-invoicing.adapter.ts | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/api/src/webhooks/http/webhook.controller.ts b/apps/api/src/webhooks/http/webhook.controller.ts index 2b94b0c1c..bc034ec44 100644 --- a/apps/api/src/webhooks/http/webhook.controller.ts +++ b/apps/api/src/webhooks/http/webhook.controller.ts @@ -70,6 +70,10 @@ export class WebhookController { @Param('connectionId') connectionId: string, @Headers() headers: Record, @Req() req: RequestWithRawBody, + // Only usage of the raw Response object in this controller — needed to + // override the route's default 202 with 200 on the handshake-echo path + // (see the res.status(HttpStatus.OK) call below). Passthrough mode still + // lets Nest serialize the returned body as normal. @Res({ passthrough: true }) res: Response, ): Promise | void> { // No `@Body() WebhookRequestDto` — the body shape is the provider's, not diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts index b8464ebe2..0217c16fc 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-inbound-webhook-decoder.adapter.ts @@ -60,7 +60,7 @@ export class InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoder const externalId = typeof parsed.resource['invoice_uuid'] === 'string' - ? (parsed.resource['invoice_uuid']) + ? parsed.resource['invoice_uuid'] : parsed.event.uuid; return { diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 233ec7cb6..6a096e8b8 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -452,8 +452,9 @@ export class InfaktInvoicingAdapter // --- Infakt-specific: trigger KSeF submission --- // Called inline by issueInvoice/issueCorrection (issuing IS submitting for - // this provider). Left public rather than private so an operator-facing - // manual re-submit can reuse it later without adding a second code path. + // this provider). Public: already called directly by + // scripts/poc-sandbox-test.ts, and kept accessible so a future + // operator-facing manual re-submit can reuse it without a second code path. async sendToKsef(invoiceUuid: string): Promise { return this.http.post( From c003797b578a9d1b88e1ecf4c54758f09b02c5ce Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Thu, 2 Jul 2026 00:37:13 +0200 Subject: [PATCH 10/27] 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 Signed-off-by: norbert-kulus-blockydevs --- .../http/dto/invoice-record-response.dto.ts | 4 ++-- .../__tests__/infakt-invoicing.adapter.spec.ts | 2 +- .../adapters/infakt-invoicing.adapter.ts | 15 +++++++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/api/src/invoicing/http/dto/invoice-record-response.dto.ts b/apps/api/src/invoicing/http/dto/invoice-record-response.dto.ts index 5c5143fa5..47b226a46 100644 --- a/apps/api/src/invoicing/http/dto/invoice-record-response.dto.ts +++ b/apps/api/src/invoicing/http/dto/invoice-record-response.dto.ts @@ -22,11 +22,11 @@ import { ApiProperty } from '@nestjs/swagger'; import type { InvoiceFailureCode, InvoiceFailureMode, -} from '@openlinker/core/invoicing'; + + InvoiceRecord} from '@openlinker/core/invoicing'; import { InvoiceFailureCodeValues, InvoiceFailureModeValues, - InvoiceRecord, InvoiceStatus, InvoiceStatusValues, RegulatoryStatus, diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts index f27c9df4c..afb5778fd 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -398,7 +398,7 @@ describe('InfaktInvoicingAdapter', () => { }); it.each([ - ['success', 'cleared'], + ['success', 'accepted'], ['pending', 'submitted'], ['sent', 'submitted'], ['error', 'rejected'], diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts index 6a096e8b8..2d8dad180 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -53,7 +53,18 @@ const SUPPORTED_DOCUMENT_TYPES: readonly DocumentType[] = [ 'prepayment', ]; -/** Maps Infakt ksef_data.status → neutral RegulatoryStatus. */ +/** + * Maps Infakt ksef_data.status → neutral RegulatoryStatus. + * + * `success` is the TERMINAL accepted state — it must map to `accepted`, not + * `cleared`. `cleared` is reserved for split-clearance regimes (no current + * provider emits it) and the FE's status card only branches on + * `submitted`/`accepted`/`rejected`, so a `cleared` mapping here left the + * badge permanently stuck at "CLEARING" and hid the clearance-reference chip + * even once the invoice had genuinely cleared on the government side + * (#1293 review, live E2E finding). Mirrors KSeF's own adapter, which maps + * its terminal 200 status to `accepted` for the exact same reason. + */ function toRegulatoryStatus(ksefStatus: InfaktKsefStatus | null | undefined): RegulatoryStatus { if (!ksefStatus) return 'not-applicable'; switch (ksefStatus) { @@ -61,7 +72,7 @@ function toRegulatoryStatus(ksefStatus: InfaktKsefStatus | null | undefined): Re case 'sent': return 'submitted'; case 'success': - return 'cleared'; + return 'accepted'; case 'error': return 'rejected'; } From d88f99ae4e9d60d421821b2e754a10f20adc5b3f Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 20:44:27 +0200 Subject: [PATCH 11/27] 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 --- .../implementation-plan-infakt-fe-plugin.md | 585 ++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 docs/plans/implementation-plan-infakt-fe-plugin.md diff --git a/docs/plans/implementation-plan-infakt-fe-plugin.md b/docs/plans/implementation-plan-infakt-fe-plugin.md new file mode 100644 index 000000000..f2edf77b4 --- /dev/null +++ b/docs/plans/implementation-plan-infakt-fe-plugin.md @@ -0,0 +1,585 @@ +# Implementation Plan: inFakt FE Plugin + Invoice-Section Host Redesign + +**Date**: 2026-07-01 +**Status**: Draft +**Estimated Effort**: 2-3 days + +--- + +## 1. Task Summary + +**Objective**: Ship the inFakt frontend plugin (guided connection setup, credentials rotation, +invoice detail region, KOR correction flow) and, alongside it, a visual redesign of the shared +`OrderInvoicePanel` regulatory-section host chrome that all three invoicing providers +(KSeF, Subiekt, inFakt) render into. + +**Context**: The inFakt backend (epic #1279) is code-complete on two open PRs — #1292 +(`InfaktInvoicingAdapter` implementing `InvoicingPort` + `RegulatoryStatusReader` + +`CorrectionIssuer`, hardening + unit tests) and #1293 (plugin registration + webhook +ingestion, stacked on #1292, still draft). Issue #1282 tracks the FE half and has already +been updated (this session) to include the correction-flow scope item that was missing from +the original spec. A design-review mockup was built and approved by the user +(https://claude.ai/code/artifact/2c542a0c-0719-4db1-b9d0-c962be23dadb, 7 screens) covering +every screen/state for both pieces of work — this plan turns that approved mockup into an +execution-ready implementation. + +**Classification**: Frontend (`apps/web`). No CORE, Integration, or Infrastructure changes — +the backend capability surface (`InvoicingPort`, `RegulatoryStatusReader`, `CorrectionIssuer`, +`POST /invoices/:invoiceId/correct`, `PUT /connections/:id/credentials`) already exists and is +generic/capability-gated. + +--- + +## 2. Scope & Non-Goals + +### In Scope + +- New `infakt` plugin under `apps/web/src/plugins/infakt/` (descriptor, invoice-detail-section, + correction-flow, structured-config section). +- Guided single-step connection setup form + page + route + (`features/connections/components/infakt-setup-form.tsx` + schema, + `pages/connections/infakt-setup-page.tsx`, `plugins/infakt/infakt-setup.route.tsx`) — mirrors + the Erli pattern (single `apiKey` credential + optional `baseUrl` config override), **not** + the generic inline `CreateConnectionForm` the original issue #1282 text implied (see + §7 Alternatives). +- `InfaktCredentialsPanel` (post-create key rotation) — near-identical port of + `ErliCredentialsPanel`. +- `InfaktStructuredSection` (post-create `baseUrl` edit in `EditConnectionForm`) — near-identical + port of `WoocommerceStructuredSection`'s single-field shape. +- `InfaktInvoiceDetailSection` (regulatory-status region: not-applicable / submitted / + accepted / rejected) — reuses the neutral `RegulatoryStatusBadge`; no `cleared` label per the + verified `RegulatoryStatusValues` contract (terminal success is `accepted` only, see §4). +- `InfaktInvoiceCorrectionFlow` (KOR modal) — near-1:1 port of `KsefInvoiceCorrectionFlow`'s + line-row model, since both ride the same generic `useIssueCorrectionMutation` + + `POST /invoices/:invoiceId/correct` contract. +- Registration in `apps/web/src/plugins/index.ts`. +- Redesign of the shared `.invoice-panel__inline-alert` / raw `.slot-row` host chrome in + `OrderInvoicePanel` (`features/invoicing/components/order-invoice-panel.tsx`) and the + KSeF/Subiekt detail sections' outer chrome, into the "reg-card" treatment validated in + mockup screen 07 (severity-stripe card, icon header, progress bar for pending, grouped + actions + copyable chip for accepted, proper `Alert`-style callout for rejected). Reuses + only existing OKLCH status tokens — no new colors. + +### Out of Scope + +- Any backend change. #1292/#1293 already ship the full capability surface this plugin + consumes. +- `WebhookProvisioningPort` UI — inFakt's webhook is BE-only per #1293's PR description + ("No `WebhookProvisioningPort` … webhook setup is UI-only, per issue scope" — meaning no FE + work needed either way). +- i18n string migration — new strings use `t(key, fallback)` per the existing no-op i18n seam + convention; no catalog entries are added (matches every other plugin today). +- Changing `RegulatoryStatusValues` / adding a `cleared`-distinct UI state — confirmed no + provider emits `cleared` today; out of scope for this plan (see §4, §5 Assumptions). +- A full visual overhaul of unrelated shared UI primitives — the redesign is scoped to the + invoice regulatory-section chrome only (§ In Scope), not a broader design-system pass (per + saved user feedback: apply incrementally, don't big-bang redesign unprompted). + +### Constraints + +- Must not introduce a new GitHub issue's worth of backend work — this is FE-only. +- Branch/PR: single branch `1282-infakt-fe-plugin` (this worktree), single PR closing #1282. + The host-panel redesign rides in the same PR (see §7 Alternatives for why a separate issue + was filed but not a separate PR). +- Must pass `pnpm --filter @openlinker/web lint`, `type-check`, `test` before commit. + +--- + +## 3. Architecture Mapping + +**Target Layer**: Frontend only — `plugins/`, `features/connections/`, `features/invoicing/`, +`pages/connections/`. + +**Capabilities Involved** (backend, already shipped): `Invoicing` (base port), +`RegulatoryStatusReader`, `CorrectionIssuer` — all resolved server-side; the FE never +capability-checks directly, it renders based on neutral `InvoiceRecord.regulatoryStatus` and +the presence of the plugin's `invoiceCorrectionFlow` / `invoiceDetailSection` slots. + +**Existing Services/Hooks Reused** (zero new hooks needed): +- `useCreateConnectionMutation`, `useTestConnectionMutation`, `useUpdateConnectionCredentialsMutation` + (`features/connections`) +- `useIssueCorrectionMutation`, `RegulatoryStatusBadge` (`features/invoicing` public barrel) +- `usePlatform` / `usePlatforms` (`shared/plugins`) +- `definePlugin` (`plugins/define-plugin`) + +**New Components Required**: +- `plugins/infakt/index.ts` (descriptor) +- `plugins/infakt/infakt-setup.route.tsx` +- `plugins/infakt/components/infakt-structured-section.tsx` +- `plugins/infakt/components/infakt-credentials-panel.tsx` +- `plugins/infakt/components/infakt-invoice-detail-section.tsx` +- `plugins/infakt/components/infakt-invoice-correction-flow.tsx` +- `features/connections/components/infakt-setup-form.tsx` +- `features/connections/components/infakt-setup.schema.ts` +- `pages/connections/infakt-setup-page.tsx` +- (redesign) modifications to `features/invoicing/components/order-invoice-panel.tsx` and a new + shared `reg-card` CSS block in `index.css`; light edits to `ksef-invoice-detail-section.tsx` + and `subiekt-invoice-detail-section.tsx` outer `
` wrappers to adopt the new class. + +**Core vs Integration Justification**: N/A — no CORE or Integration change. All contract +surfaces (`InvoiceRecord`, `RegulatoryStatus`, `POST /invoices/:invoiceId/correct`, +`PUT /connections/:id/credentials`) are pre-existing and generic. + +--- + +## 4. External / Domain Research + +### Backend contract (verified against `1280-infakt-plugin-hardening-tests` branch, PR #1292) + +- `InfaktCredentials = { apiKey: string }`, `InfaktConnectionConfig = { baseUrl?: string }` + (`libs/integrations/infakt/src/domain/types/infakt-connection.types.ts`) — confirms issue + #1282's field list is accurate and minimal. +- `InfaktInvoicingAdapter implements InvoicingPort, RegulatoryStatusReader, CorrectionIssuer` — + `issueCorrection` is real, confirming the correction-flow scope item added to #1282 this + session is backed by working code, not speculative. +- adapterKey: `infakt.accounting.v1` (per epic #1279 / PR #1293 description). + +### Neutral invoicing contract (verified against `apps/web/src/features/invoicing/api/invoicing.types.ts`, current main) + +- `RegulatoryStatusValues = ['not-applicable', 'submitted', 'cleared', 'accepted', 'rejected']` + but the file's own doc-comment states: *"Terminal success is `accepted`… `cleared` is + reserved for split-clearance regimes and no current provider emits it, so the FE never + renders a `cleared` success label."* This **corrects** one of the mockup's open design + questions (mockup screen 04 flagged uncertainty between `cleared` vs `accepted`) — the + answer is: **only `accepted` gets the success/UPO-style treatment; `cleared` needs no + distinct UI in this plan.** +- `InvoiceRecord` has no seller/buyer/line-item fields — confirmed by the mockup agent's own + research; `InfaktInvoiceDetailSection` renders only from `clearanceReference`, + `providerInvoiceNumber`, `regulatoryStatus`, `failureReason`. +- `POST /invoices/:invoiceId/correct` body: `{ reason?, lines: [{ originalLineNumber, + newQuantity?, newUnitPriceGross? }], idempotencyKey? }` — identical shape KSeF already uses; + `InfaktInvoiceCorrectionFlow` needs no new types. + +### Internal patterns (verified by reading current-main source, this session) + +- **Setup flow**: every FE plugin with a non-OAuth single/few-credential shape (Erli is the + closest analog to inFakt: one `apiKey` + optional `baseUrl`) ships a guided route + (`plugins//-setup.route.tsx`) → page (`pages/connections/-setup-page.tsx`) + → form (`features/connections/components/-setup-form.tsx` + `.schema.ts`). The + `PlatformPicker` component (`features/connections/components/platform-picker.tsx`) always + navigates via `setupCard.to` to that dedicated route — there is **no** inline generic-form + path for a plugin with a `setupCard` (the generic `CreateConnectionForm`'s raw-JSON path is + reserved for platforms with no plugin at all, or the `/connections/new/advanced` escape + hatch). This is a **correction to issue #1282's original text**, which implied wiring + `StructuredConfigSection` into the generic create form — the actual codebase convention is + the guided-form pattern. Erli's `ErliSetupForm` (`features/connections/components/erli-setup-form.tsx`) + + `erli-setup.schema.ts` are the reference implementation to port almost verbatim. +- **Credentials rotation**: `ErliCredentialsPanel` is the exact single-`apiKey` analog (KSeF's + panel additionally rotates an `authType` enum, which inFakt doesn't have). +- **Post-create structured-edit**: `WoocommerceStructuredSection`'s single-field `siteUrl` + shape is the closest analog for editing `baseUrl` after creation via `EditConnectionForm` + (Erli deliberately skips this and falls back to raw JSON — inFakt keeps it per #1282's + explicit ask, and the field count is the same as WooCommerce's, so the precedent is sound). +- **Invoice detail section**: `SubiektInvoiceDetailSection` is the simpler analog (badge + KV + rows, no dialog); `KsefInvoiceDetailSection` shows the same shape plus UPO/FA(3) dialogs that + inFakt does not need (inFakt has no UPO/FA3 endpoints — it only reports KSeF status + + `clearanceReference`, matching Subiekt's posture more closely than KSeF's). **Decision**: + base `InfaktInvoiceDetailSection` on `SubiektInvoiceDetailSection`'s structure (badge + KV, + no dialogs), not KSeF's. +- **Correction flow**: `KsefInvoiceCorrectionFlow` (`plugins/ksef/components/ksef-invoice-correction-flow.tsx`) + is the exact contract match (`InvoiceCorrectionFlowProps`, `useIssueCorrectionMutation`, + same line-row model) — port near-verbatim, only the section title / copy changes. + `SubiektInvoiceCorrectionFlow` exists too and is worth a quick diff-check during + implementation in case it diverged from KSeF's in a way worth adopting, but KSeF is the + primary reference since the mockup was built against it. +- **`OrderInvoicePanel` current host chrome** (verified by reading the full current + implementation): it is **already more polished than the mockup's "before" baseline assumed** + — it has `InvoiceStatusBadge` + `RegulatoryStatusBadge` in the header, a real + `.invoice-panel__inline-alert` component for `failed`/`in-doubt` (colored bar + bold title + + body), and a proper KV block for `issued`. The redesign's real value-add is: (a) a dedicated + card treatment for the *provider's own* `invoiceDetailSection` slot content (today it's a + bare `
` inside the KV block, mockup's "after" wraps it in a severity-stripe card), + and (b) consistent iconography + a progress bar for the `submitted`/pending window. Plan + scope is narrowed accordingly — see Phase 2 below. + +--- + +## 5. Questions & Assumptions + +### Open Questions + +- Setup-card icon/monogram and its color (mockup open item #1/#2) — no existing plugin uses an + icon badge on its setup card (KSeF, Erli, WooCommerce all use title + `badge` chip only). + **Default**: drop the invented "iF" monogram, match the established title+badge-chip + convention exactly (no icon) unless the user says otherwise. +- Whether the host-panel redesign should also touch `not-issued` / `pending` / `issuing` / + `failed` / `in-doubt` panel states beyond the provider-slot card, or stay narrowly scoped to + the provider `invoiceDetailSection` region. **Default**: narrow scope — only the provider + slot's card wrapper + its internal submitted/accepted/rejected states change; the panel's + own `not-issued`/`pending`/`issuing`/`failed`/`in-doubt` states (which are already + well-designed per §4) are left untouched in this plan. + +### Assumptions + +- `cleared` needs no distinct UI (confirmed in §4 — safe default, not a guess). +- inFakt has no PDF/UPO/FA3-equivalent document to surface — only `clearanceReference` / + `providerInvoiceNumber` / `failureReason`. If this turns out wrong once #1292/#1293 merge + and a real sandbox response is inspected, `InfaktInvoiceDetailSection` is a single small + component to extend later — not a blocking assumption. +- The host-panel redesign is CSS/markup-only — no new props on `InvoiceDetailSectionProps` / + `InvoiceCorrectionFlowProps` are needed. The card wrapper can be added by `OrderInvoicePanel` + around the slot's rendered output without changing the slot contract, since the slot already + renders a self-contained `
` (see `SubiektInvoiceDetailSection`) — the host can wrap + that section in a `.reg-card` div, or the redesign can standardize each section's own root + class name to `reg-card` directly. **Decision**: change the section components' own root + class (KSeF, Subiekt, inFakt all switch to `reg-card` + a `--tone` modifier) rather than + double-wrapping from the host, since the section owns its own semantic content (title, rows) + and is best positioned to know its own severity tone. + +### Documentation Gaps + +- None — `docs/frontend-architecture.md` § Platform Plugins fully documents the slot contract; + the guided-setup-route pattern isn't written down as a named convention anywhere, it only + exists as consistent precedent across 6+ plugins. Worth a follow-up doc note, out of scope + for this plan. + +--- + +## 6. Proposed Implementation Plan + +### Phase 1: inFakt plugin — connection lifecycle + +**Goal**: An operator can create, view, and rotate credentials for an inFakt connection. + +**Steps**: + +1. **Setup schema** + - **File**: `apps/web/src/features/connections/components/infakt-setup.schema.ts` + - **Action**: Port `erli-setup.schema.ts` verbatim, renaming `ERLI_ADAPTER_KEY` → + `INFAKT_ADAPTER_KEY = 'infakt.accounting.v1'`, `platformType: 'infakt'`. Keep the same + `name` / `apiKey` / `baseUrl` (https-only, optional) Zod shape. + - **Acceptance**: `infaktSetupSchema` exports match Erli's shape 1:1 minus naming. + +2. **Setup form** + - **File**: `apps/web/src/features/connections/components/infakt-setup-form.tsx` + - **Action**: Port `erli-setup-form.tsx` verbatim, swap copy ("inFakt" / "API key from your + inFakt account settings" instead of "Shop API key" / seller-panel copy), swap the + `BackLink` target stays `/connections/new`, swap toast copy. + - **Acceptance**: form renders name/apiKey/baseUrl fields, submit creates the connection via + `useCreateConnectionMutation`, shows "Test connection" affordance after create (reuses + `useTestConnectionMutation` — no changes needed there). + +3. **Setup page + route** + - **Files**: `apps/web/src/pages/connections/infakt-setup-page.tsx`, + `apps/web/src/plugins/infakt/infakt-setup.route.tsx` + - **Action**: Port `erli-setup-page.tsx` / `erli-setup.route.tsx` verbatim with inFakt copy + and path `connections/new/infakt`. + - **Acceptance**: navigating to `/connections/new/infakt` renders the page with the form. + +4. **Credentials panel** + - **File**: `apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx` + - **Action**: Port `ErliCredentialsPanel` verbatim (single `apiKey` rotate, same + `credentialsBacked` fallback). + - **Acceptance**: matches `erli-credentials-panel.test.tsx`'s five assertions when ported + to an `infakt-credentials-panel.test.tsx` (see Phase 4). + +5. **Structured config section (post-create `baseUrl` edit)** + - **File**: `apps/web/src/plugins/infakt/components/infakt-structured-section.tsx` + - **Action**: Port `WoocommerceStructuredSection`'s single-field pattern, one `FormField` + for `baseUrl` bound via `syncStructuredToJson('baseUrl', value)`, helper text explaining + sandbox vs. production (per issue #1282's original ask). + - **Acceptance**: editing an existing inFakt connection's `baseUrl` in `EditConnectionForm` + round-trips through `configText` JSON correctly (mirror + `woocommerce-structured-section.test.tsx`'s assertions). + +### Phase 2: inFakt plugin — invoice surfacing + +**Goal**: Regulatory status and KOR corrections are visible/actionable on an inFakt-issued +invoice, using the redesigned host chrome. + +**Steps**: + +6. **Redesign the provider-slot host chrome** (do this before wiring inFakt's own section, so + inFakt is born with the new look and KSeF/Subiekt get it as a drive-by improvement) + - **Files**: + - `apps/web/src/index.css` — new bounded section `/* ── Regulatory section card (#1282) ── */` + adding `.reg-card`, `.reg-card--info` / `.reg-card--success` / `.reg-card--error` + (severity-stripe via `border-left` + existing `--status-*` tokens, no new colors), + `.reg-card__header` (icon + title row), `.reg-card__progress` (indeterminate bar for + `submitted`), `.reg-card__summary` (accepted: reference chip + grouped actions). + - `apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx` — change the root + `
` to also + carry `reg-card reg-card--{tone}` (tone derived from `regulatoryStatus`: `submitted` → + info, `accepted` → success, `rejected` → error, `not-applicable`/other → no card, keep + returning `null`). + - `apps/web/src/plugins/subiekt/components/subiekt-invoice-detail-section.tsx` — same + root-class change. + - **Acceptance**: existing KSeF/Subiekt detail-section tests still pass unchanged (class + name is additive, not a DOM restructure) — run + `pnpm --filter @openlinker/web test ksef-invoice-detail-section subiekt-invoice-detail-section` + to confirm no visual-only test asserts the old class name exclusively. + - **Risk**: if any existing test asserts `container.querySelector('.invoice-detail-section')` + exactly, keep that class alongside the new one (additive) rather than replacing it — see + §8 Risks. + +7. **Invoice detail section** + - **File**: `apps/web/src/plugins/infakt/components/infakt-invoice-detail-section.tsx` + - **Action**: Base on `SubiektInvoiceDetailSection`'s structure (badge + KV rows, no + dialogs), applying the new `reg-card` root class from step 6 directly (inFakt ships with + the redesigned look from day one — no "before" version to migrate). Render: + - `not-applicable` → return `null` (no card) + - `submitted` → `reg-card--info`, `RegulatoryStatusBadge`, progress bar, "Pending KSeF + clearance…" copy + - `accepted` → `reg-card--success`, `RegulatoryStatusBadge`, KV row with + `clearanceReference` (copyable — reuse existing copy-to-clipboard affordance if one + exists in `shared/ui`, else a plain `` + a small "Copy" button using + `navigator.clipboard.writeText`) + - `rejected` → `reg-card--error`, `RegulatoryStatusBadge`, `failureReason` text + - **Acceptance**: matches mockup screen 04's four states exactly; unit test covers all four + `regulatoryStatus` values plus the `not-applicable` no-render case. + +8. **Correction flow** + - **File**: `apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx` + - **Action**: Port `KsefInvoiceCorrectionFlow` near-verbatim (per the issue #1282 update + made this session) — same line-row model (`originalLineNumber` / `newQuantity` / + `newUnitPriceGross`), one empty row + "Add line", reuses + `useIssueCorrectionMutation` from `features/invoicing`. Swap only the dialog title/copy + to inFakt-specific wording. + - **Acceptance**: matches `ksef-invoice-correction-flow.test.tsx`'s assertions 1:1 when + ported (empty-row start, add-line, validation, submit success/error). + +### Phase 3: Registration + +**Steps**: + +9. **Plugin descriptor** + - **File**: `apps/web/src/plugins/infakt/index.ts` + - **Action**: + ```typescript + export const infaktPlugin: OpenLinkerPlugin = definePlugin({ + id: 'infakt', + platformType: 'infakt', + build: { routes: [infaktSetupRoute] }, + platform: { + displayName: 'inFakt', + setupCard: { + title: 'inFakt', + description: 'Polish accounting platform with native KSeF integration', + to: '/connections/new/infakt', + badge: 'API key', + }, + StructuredConfigSection: InfaktStructuredSection, + CredentialsPanel: InfaktCredentialsPanel, + invoiceDetailSection: InfaktInvoiceDetailSection, + invoiceCorrectionFlow: InfaktInvoiceCorrectionFlow, + }, + }); + ``` + - **Acceptance**: `assertUniquePluginInvariants` passes with the new entry (no id/platformType + collision — trivially true, `infakt` is new). + +10. **Barrel registration** + - **File**: `apps/web/src/plugins/index.ts` + - **Action**: add `import { infaktPlugin } from './infakt';` and append to the `plugins` + array (position: after `subiektPlugin`, before `ksefPlugin`, keeping the existing + invoicing-providers-last-ish grouping — exact order doesn't affect correctness, only + setup-card display order). + - **Acceptance**: `/connections/new` shows the inFakt card; `pnpm --filter @openlinker/web test` + green (covers the module-load duplicate-id/platformType assertion). + +### Implementation Details + +**New Components**: listed in §3 above — all `apps/web`, no domain/application/infrastructure +layers touched (this is a pure-FE plan). + +**Configuration Changes**: none (no new env vars — inFakt's `baseUrl` is a per-connection +config field, not a build-time var). + +**Database Migrations**: none. + +**Events**: none emitted or consumed — this is synchronous CRUD + query/mutation hooks only. + +**Error Handling**: reuses existing `ApiError` normalization (`useCreateConnectionMutation`, +`useUpdateConnectionCredentialsMutation`, `useIssueCorrectionMutation` all already surface +`.error` for the form/panel to render via `Alert`). No new error types. + +--- + +## 7. Alternatives Considered + +### Alternative 1: Wire `StructuredConfigSection` into the generic `CreateConnectionForm` (issue #1282's literal text) + +- **Description**: Skip the guided setup route/page/form and rely on the generic + `/connections/new` inline form (raw JSON config + credentials JSON) with inFakt's + `StructuredConfigSection` swapped in. +- **Why Rejected**: Verified against the actual `CreateConnectionForm` + `PlatformPicker` + source: every plugin with a `setupCard` unconditionally navigates to its own dedicated route + via `setupCard.to` — there is no live code path where `StructuredConfigSection` renders + inside the generic form for a plugin that also has a `setupCard`. Issue #1282's text + described a shape that doesn't match any existing plugin's actual wiring. Following the + guided-route pattern (Erli is the near-identical analog: one credential + optional advanced + URL) is both correct and less work per-field than reproducing the generic form's raw-JSON UX. +- **Trade-offs**: none of substance — the guided-form approach is strictly better UX + (dedicated instructional copy, inline "Test connection" affordance) and is what every + comparable plugin already does. + +### Alternative 2: Open a separate PR (and possibly a separate issue) for the host-panel redesign + +- **Description**: File a standalone issue + PR for the `reg-card` redesign, keep the inFakt + plugin PR narrowly scoped to #1282. +- **Why Rejected**: The redesign's only realistic motivation right now is "inFakt's new + section should look like the approved mockup" — splitting it into a separate PR would mean + either (a) inFakt ships with the *old* bare-section look first and gets restyled later + (churn, two review passes on the same lines), or (b) the redesign PR has to land first and + block inFakt for no benefit. Per the user's saved preference (single PR for plan + + implementation; apply UI-redesign direction incrementally as related work touches each + component — not as a big-bang), bundling the two is more consistent with how this specific + redesign was scoped: as *inFakt's* section design, generalized to its two existing siblings + as a drive-by improvement since they share one CSS class contract. +- **Trade-offs**: the PR diff is slightly larger (touches 2 existing provider sections instead + of 0) and a reviewer sees an unrelated-looking CSS section in an "inFakt plugin" PR — mitigated + by a clear PR description section explaining the redesign is additive/non-breaking and + why it's bundled. + +--- + +## 8. Validation & Risks + +### Architecture Compliance + +- ✅ No CORE/Integration/Infrastructure changes — pure `apps/web` work. +- ✅ Dependency direction respected: `plugins/infakt/*` imports only `features/invoicing`, + `features/connections` (public barrels) and `shared/*`; the setup form itself lives in + `features/connections/components/` per the established convention (pages deep-import it, + which is a documented, accepted gap — see `docs/frontend-architecture.md` § Feature Public + Surface, "Out of scope today"). +- ✅ No plugin does `platformType === 'infakt'` string comparison anywhere outside + `plugins/infakt/` — all cross-cutting logic stays capability/slot-driven. + +### Naming Conventions + +- ✅ Components: `kebab-case.tsx` files, `PascalCase` exports (`InfaktCredentialsPanel`, etc.) +- ✅ Hooks: none new. +- ✅ Route module: `infakt-setup.route.tsx`. +- ✅ Tests: `*.test.tsx` colocated. + +### Existing Patterns + +- ✅ Guided setup flow matches Erli precedent exactly. +- ✅ Credentials panel matches Erli precedent exactly. +- ✅ Structured section matches WooCommerce precedent exactly. +- ✅ Correction flow matches KSeF precedent exactly (mandated by the shared backend contract). + +### Risks + +- **Redesign regresses existing KSeF/Subiekt visual tests**: mitigated by making the class + change additive (keep old class, add new) rather than a replace, and running the existing + test suites for both sections before considering Phase 2 Step 6 done (see acceptance + criteria in Step 6). +- **`baseUrl` sandbox-override semantics unclear to operators**: mitigated by the helper text + specified in Phase 1 Step 5 (explicit "leave blank for production" copy), matching Erli's + and Woo's existing helper-text conventions. +- **inFakt sandbox response shape for `clearanceReference` diverges from assumption**: low + risk (assumption documented in §5, cheap to fix — one component, no contract change needed) + since the neutral `InvoiceRecord` shape is already fixed regardless of provider. + +### Edge Cases + +- Connection with `credentialsBacked: false` (env-var-backed) → `InfaktCredentialsPanel` falls + back to the disabled read-only input, matching Erli's tested behavior exactly. +- Multiple active inFakt connections → already handled by `OrderInvoicePanel`'s existing + connection-picker logic (`selectInvoicingConnections`), no inFakt-specific change needed. +- `regulatoryStatus === 'cleared'` (never emitted today per §4) → `InfaktInvoiceDetailSection` + should not crash if it ever appears; treat as a no-card/no-render fallback identical to + `not-applicable` (safe default, matches the "FE never renders a cleared label" contract). + +### Backward Compatibility + +- ✅ No breaking changes — additive plugin registration, additive CSS classes, no changes to + `InvoiceDetailSectionProps` / `InvoiceCorrectionFlowProps` / any exported type. + +--- + +## 9. Testing Strategy & Acceptance Criteria + +### Unit Tests + +- `apps/web/src/features/connections/components/infakt-setup-form.test.tsx` — port + `erli-setup-form`'s test suite shape if one exists (check during implementation; if Erli's + form has no dedicated test file, write one covering: renders fields, submit calls + `createConnection.mutateAsync` with the right payload, shows test-connection affordance + after create). +- `apps/web/src/features/connections/components/infakt-setup.schema.test.ts` (if Erli's schema + has one — port it) — validates `apiKey` required, `baseUrl` optional/https-only. +- `apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx` — port + `erli-credentials-panel.test.tsx`'s 5 cases verbatim (renders rotate affordance, env-backed + fallback, sends new apiKey + toast, disables save while empty, collapses after success). +- `apps/web/src/plugins/infakt/components/infakt-structured-section.test.tsx` — port + `woocommerce-structured-section.test.tsx`'s applicable cases (renders field, propagates via + `syncStructuredToJson`, disables when `!configIsParseable`, shows validation error). +- `apps/web/src/plugins/infakt/components/infakt-invoice-detail-section.test.tsx` — new: one + case per `RegulatoryStatus` value (`not-applicable` → null, `submitted` → pending copy + + progress bar, `accepted` → clearance reference chip, `rejected` → failure reason visible). +- `apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx` — port + `ksef-invoice-correction-flow.test.tsx`'s cases verbatim. +- `apps/web/src/plugins/index.test.ts` (existing suite, if present covering plugin count/ids) — + update expected count / add `infakt` to any hardcoded id list. +- Redesign regression: re-run `ksef-invoice-detail-section.test.tsx` and + `subiekt-invoice-detail-section.test.tsx` unmodified — must stay green after the additive + class change. + +### Integration Tests + +- None needed — no backend/API changes; existing `apps/api` int-specs for + `/connections`, `/invoices/*` already cover the generic endpoints this plugin calls. + +### Mocking Strategy + +- All tests use `createMockApiClient()` / `renderWithProviders()` from + `apps/web/src/test/test-utils.tsx` per the existing convention — no real network calls, no + new mocking infrastructure needed. + +### Acceptance Criteria + +- [ ] `pnpm --filter @openlinker/web lint` passes +- [ ] `pnpm --filter @openlinker/web type-check` passes +- [ ] `pnpm --filter @openlinker/web test` passes (all new + all existing invoicing/connections + suites) +- [ ] `/connections/new` shows the inFakt card (title "inFakt", badge "API key", no icon) +- [ ] Selecting the inFakt card navigates to `/connections/new/infakt` and shows the guided form +- [ ] Empty API key on submit shows a validation error, no request sent +- [ ] Successful create shows the connection in the list with `infakt` platformType, offers + "Test connection" +- [ ] Editing an existing inFakt connection shows the masked-key credentials panel + rotate, + and the `baseUrl` structured field +- [ ] An inFakt invoice with `regulatoryStatus: 'submitted'` shows the info-tone reg-card with + a progress indicator +- [ ] An inFakt invoice with `regulatoryStatus: 'accepted'` shows the success-tone reg-card + with a copyable `clearanceReference` +- [ ] An inFakt invoice with `regulatoryStatus: 'rejected'` shows the error-tone reg-card with + `failureReason` visible +- [ ] "Issue correction" opens the KOR modal with one empty line row + "Add line"; missing + line number blocks submit; success closes the dialog + refetches the invoice +- [ ] KSeF and Subiekt invoice-detail-section existing tests remain green after the redesign + +--- + +## 10. Alignment Checklist + +- [x] Follows hexagonal architecture (N/A — pure FE, no layer violations introduced) +- [x] Respects CORE vs Integration boundaries (no CORE/Integration touched) +- [x] Uses existing patterns (Erli setup flow, WooCommerce structured section, KSeF/Subiekt + detail sections and correction flow — all ported, not invented) +- [x] Idempotency considered (correction submit reuses the existing idempotency-keyed mutation; + no new mutating flow introduced) +- [ ] Event-driven patterns used where applicable — N/A, no events in this slice +- [ ] Rate limits & retries addressed — N/A, no new external calls (all via existing generic + connection/invoice endpoints) +- [x] Error handling comprehensive (reuses existing `ApiError` + `Alert` patterns throughout) +- [x] Testing strategy complete (§9) +- [x] Naming conventions followed (§8) +- [x] File structure matches standards (§3, §6) +- [x] Plan is execution-ready +- [x] Plan is saved as markdown file + +--- + +## Related Documentation + +- [Frontend Architecture](../frontend-architecture.md) +- [Engineering Standards](../engineering-standards.md) +- [Testing Guide](../testing-guide.md) +- [Code Review Guide](../code-review-guide.md) +- Mockup artifact: https://claude.ai/code/artifact/2c542a0c-0719-4db1-b9d0-c962be23dadb +- Issue #1282 (FE plugin, correction-flow scope added 2026-07-01) +- PR #1292 (backend hardening), PR #1293 (backend registration + webhook, draft) From 3b87a21879beca8e0065e5a8b82eb016d3a98d97 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 20:56:38 +0200 Subject: [PATCH 12/27] 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 --- .../implementation-plan-infakt-fe-plugin.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/plans/implementation-plan-infakt-fe-plugin.md b/docs/plans/implementation-plan-infakt-fe-plugin.md index f2edf77b4..4b0d2e65b 100644 --- a/docs/plans/implementation-plan-infakt-fe-plugin.md +++ b/docs/plans/implementation-plan-infakt-fe-plugin.md @@ -53,6 +53,11 @@ generic/capability-gated. line-row model, since both ride the same generic `useIssueCorrectionMutation` + `POST /invoices/:invoiceId/correct` contract. - Registration in `apps/web/src/plugins/index.ts`. +- Two Playwright evidence scripts (`apps/web/e2e/infakt-connection.mjs`, + `apps/web/e2e/infakt-invoice.mjs`) run once locally against the real inFakt sandbox, producing + `docs/assets/infakt/*.png` screenshots committed to the branch and posted inline in a PR + comment — proof that connecting inFakt and issuing/clearing/correcting a real invoice through + OpenLinker actually works end to end, not just that the UI renders (see §6 Phase 4). - Redesign of the shared `.invoice-panel__inline-alert` / raw `.slot-row` host chrome in `OrderInvoicePanel` (`features/invoicing/components/order-invoice-panel.tsx`) and the KSeF/Subiekt detail sections' outer chrome, into the "reg-card" treatment validated in @@ -377,6 +382,59 @@ invoice, using the redesigned host chrome. - **Acceptance**: `/connections/new` shows the inFakt card; `pnpm --filter @openlinker/web test` green (covers the module-load duplicate-id/platformType assertion). +### Phase 4: E2E Playwright verification + screenshot evidence + +**Goal**: Prove, with real browser screenshots against a running local stack + the real inFakt +sandbox, that an operator can connect inFakt and issue a real invoice through OpenLinker end to +end (OL → `InfaktInvoicingAdapter` → inFakt sandbox API → KSeF) — not just that the components +render. Follows the exact precedent already established for Subiekt/Erli +(`apps/web/e2e/subiekt-invoice.mjs`, `apps/web/e2e/subiekt-proofs.mjs`): plain Playwright `.mjs` +scripts (not `*.spec.ts` — these are one-off evidence-capture walkthroughs, not part of `pnpm test`), +run manually against `pnpm --filter @openlinker/web preview` (port 4173), saving screenshots to +`docs/assets/infakt/` and committing them to the PR branch (the established convention — see +`docs/assets/subiekt/*.png`, already tracked in git). + +**Steps**: + +11. **Connection walkthrough script** + - **File**: `apps/web/e2e/infakt-connection.mjs` + - **Action**: Port `subiekt-invoice.mjs`'s login/shot helper pattern. Drive: `/connections/new` + → click inFakt card → fill name + real sandbox API key (from environment/local secrets — + **never hardcode the sandbox key in the script or commit it**; read via + `process.env.INFAKT_SANDBOX_API_KEY`) → submit → "Test connection" → connection list showing + the new `infakt` connection. Capture one screenshot per step into + `docs/assets/infakt/{00..05}-*.png`. + - **Acceptance**: running the script against a local `pnpm --filter @openlinker/web preview` + + `pnpm start:dev:api` stack with real sandbox credentials produces a green "Connection test + passed" screenshot. + +12. **Invoice issuance walkthrough script** + - **File**: `apps/web/e2e/infakt-invoice.mjs` + - **Action**: Port `subiekt-invoice.mjs`'s `issueFlow` near-verbatim: open a real ingested + order → invoice panel not-issued state → click "Issue invoice" → wait for the real + OL → inFakt → KSeF round-trip → capture the `submitted` reg-card, then poll/reload until + `regulatoryStatus` flips to `accepted` (KSeF clearance in sandbox is ~90s per the earlier + feasibility POC finding) and capture the `accepted` reg-card with the clearance reference + chip. Also drive "Issue correction" once on the accepted invoice to capture the KOR modal + states + the resulting corrected-document row on `/invoices`. + - **Acceptance**: screenshots exist proving (a) invoice issued, (b) KSeF clearance reached + `accepted` through the real sandbox (not mocked), (c) a correction was issued successfully. + This is the concrete "we can issue an invoice through OpenLinker and inFakt" confirmation + the user asked for. + +13. **Screenshot evidence in the PR** + - **Action**: After both scripts run successfully and `docs/assets/infakt/*.png` are + committed to the branch, post a PR comment (via `gh pr comment`) embedding the screenshots + as markdown images referencing + `https://raw.githubusercontent.com/openlinker-project/openlinker/1282-infakt-fe-plugin/docs/assets/infakt/{name}.png` + — the same raw-content-URL technique the repo already relies on for rendering + `docs/assets/*` images works here too, because the files are committed to the PR's own + branch, so the images render inline in the GitHub comment UI with no external upload step. + Structure the comment as a short walkthrough: setup → issue → clearance → correction, one + image per step with a one-line caption. + - **Acceptance**: the PR has a comment with inline-rendered screenshots covering the full + connect → issue → clear → correct flow. + ### Implementation Details **New Components**: listed in §3 above — all `apps/web`, no domain/application/infrastructure @@ -523,6 +581,16 @@ config field, not a build-time var). - None needed — no backend/API changes; existing `apps/api` int-specs for `/connections`, `/invoices/*` already cover the generic endpoints this plugin calls. +### E2E Verification (Phase 4) + +- `apps/web/e2e/infakt-connection.mjs` and `apps/web/e2e/infakt-invoice.mjs` — manual-run + Playwright walkthroughs (not part of `pnpm test` / CI), run once against a live local stack + + the real inFakt sandbox before opening the PR. Produces `docs/assets/infakt/*.png`, + committed to the branch, then posted as an inline-image PR comment (§6 Phase 4 Step 13). + This is the one part of the plan that requires real external sandbox credentials and a + running `pnpm start:dev:api` + `pnpm --filter @openlinker/web preview` stack — everything + else in this plan is verified by `pnpm test` alone. + ### Mocking Strategy - All tests use `createMockApiClient()` / `renderWithProviders()` from @@ -551,6 +619,10 @@ config field, not a build-time var). - [ ] "Issue correction" opens the KOR modal with one empty line row + "Add line"; missing line number blocks submit; success closes the dialog + refetches the invoice - [ ] KSeF and Subiekt invoice-detail-section existing tests remain green after the redesign +- [ ] `docs/assets/infakt/*.png` exist proving a real sandbox connection + issuance + KSeF + clearance (`accepted`) + a correction, captured via `infakt-connection.mjs` / `infakt-invoice.mjs` +- [ ] The PR has a comment with those screenshots rendered inline (raw.githubusercontent.com + image links against the PR branch) --- From 33b5cfaacc60ef85e7aa3f6d7379894c2a374030 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 21:07:39 +0200 Subject: [PATCH 13/27] =?UTF-8?q?feat(web):=20inFakt=20FE=20plugin=20?= =?UTF-8?q?=E2=80=94=20connection=20setup=20+=20invoice=20detail=20+=20KOR?= =?UTF-8?q?=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/app/routes/route-lazy.test.ts | 5 +- .../components/infakt-setup-form.test.tsx | 222 ++++++++++++++ .../components/infakt-setup-form.tsx | 194 ++++++++++++ .../components/infakt-setup.schema.ts | 61 ++++ apps/web/src/index.css | 95 ++++++ .../pages/connections/infakt-setup-page.tsx | 26 ++ apps/web/src/plugins/index.ts | 2 + .../infakt-credentials-panel.test.tsx | 81 +++++ .../components/infakt-credentials-panel.tsx | 102 +++++++ .../infakt-invoice-correction-flow.test.tsx | 222 ++++++++++++++ .../infakt-invoice-correction-flow.tsx | 289 ++++++++++++++++++ .../infakt-invoice-detail-section.test.tsx | 124 ++++++++ .../infakt-invoice-detail-section.tsx | 82 +++++ .../infakt-structured-section.test.tsx | 103 +++++++ .../components/infakt-structured-section.tsx | 42 +++ apps/web/src/plugins/infakt/index.ts | 49 +++ .../src/plugins/infakt/infakt-setup.route.tsx | 16 + .../ksef-invoice-detail-section.tsx | 14 +- .../subiekt-invoice-detail-section.tsx | 12 +- .../implementation-plan-infakt-fe-plugin.md | 29 +- 20 files changed, 1762 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/features/connections/components/infakt-setup-form.test.tsx create mode 100644 apps/web/src/features/connections/components/infakt-setup-form.tsx create mode 100644 apps/web/src/features/connections/components/infakt-setup.schema.ts create mode 100644 apps/web/src/pages/connections/infakt-setup-page.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-invoice-detail-section.test.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-invoice-detail-section.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-structured-section.test.tsx create mode 100644 apps/web/src/plugins/infakt/components/infakt-structured-section.tsx create mode 100644 apps/web/src/plugins/infakt/index.ts create mode 100644 apps/web/src/plugins/infakt/infakt-setup.route.tsx diff --git a/apps/web/src/app/routes/route-lazy.test.ts b/apps/web/src/app/routes/route-lazy.test.ts index 481e71fd8..48e4c4760 100644 --- a/apps/web/src/app/routes/route-lazy.test.ts +++ b/apps/web/src/app/routes/route-lazy.test.ts @@ -57,13 +57,14 @@ const lazyRoutes = collectLazyRoutes([ * `/users` user-management page (#1125), and `/invoices/:invoiceId` detail (#1240)) * - 3 guest routes (forgot-password, reset-password, register — login stays eager) * - 9 plugin routes (allegro callback + setup, prestashop setup, dpd setup, - * woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup) + * woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup, + * infakt setup (#1282)) * * Routes that are intentionally eager (no page module to defer): * - login (first-paint optimization — see `login.route.tsx`) * - prompt-templates-legacy-redirects (inline `` element) */ -const EXPECTED_LAZY_ROUTE_COUNT = 48; +const EXPECTED_LAZY_ROUTE_COUNT = 49; describe('route lazy contract', () => { it(`the registered route tree contains exactly ${EXPECTED_LAZY_ROUTE_COUNT} lazy routes`, () => { diff --git a/apps/web/src/features/connections/components/infakt-setup-form.test.tsx b/apps/web/src/features/connections/components/infakt-setup-form.test.tsx new file mode 100644 index 000000000..1125c4c75 --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup-form.test.tsx @@ -0,0 +1,222 @@ +/** + * InfaktSetupForm Tests + * + * Coverage for the single-step inFakt setup wizard. Tests form validation, + * submission via the generic create-connection mutation, and the post-create + * "Test connection" flow that surfaces a ConnectionTestResult. Mirrors + * `erli-setup-form.test.tsx`. + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + findToastTitle, + renderWithProviders, +} from '../../../test/test-utils'; +import { InfaktSetupForm } from './infakt-setup-form'; + +describe('InfaktSetupForm', () => { + afterEach(cleanup); + + it('renders the required form fields', () => { + renderWithProviders(); + expect(screen.getByLabelText('Connection name')).toBeInTheDocument(); + expect(screen.getByLabelText('API key')).toBeInTheDocument(); + expect(screen.getByLabelText('Base URL (optional)')).toBeInTheDocument(); + }); + + it('requires connection name to be non-empty', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('Connection name is required')[0]).toBeInTheDocument(); + }); + }); + + it('requires the API key to be non-empty', async () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('API key is required')[0]).toBeInTheDocument(); + }); + }); + + it('rejects a non-HTTPS base URL override', async () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.change(screen.getByLabelText('Base URL (optional)'), { + target: { value: 'http://api.infakt.pl' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('Base URL must use HTTPS')[0]).toBeInTheDocument(); + }); + }); + + it('submits the API key and omits config when no base URL is given', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My inFakt Account', + platformType: 'infakt', + adapterKey: 'infakt.accounting.v1', + config: {}, + credentials: { apiKey: 'sk_test_123' }, + }), + ); + }); + expect(await findToastTitle('Connection created')).toBeInTheDocument(); + }); + + it('includes baseUrl in config when supplied', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.change(screen.getByLabelText('Base URL (optional)'), { + target: { value: 'https://sandbox.infakt.pl' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + config: { baseUrl: 'https://sandbox.infakt.pl' }, + }), + ); + }); + }); + + it('surfaces the test-connection result after a successful create', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi + .fn() + .mockResolvedValue({ success: true, status: 200, message: 'OK', latencyMs: 42 }); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + // After create, the test affordance replaces the connect button. + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + await waitFor(() => { + expect(test).toHaveBeenCalledWith('conn-1'); + }); + expect(await screen.findByText('Connection test passed')).toBeInTheDocument(); + }); + + it('surfaces a failing test-connection result', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi + .fn() + .mockResolvedValue({ success: false, status: 401, message: 'Unauthorized', latencyMs: 10 }); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + expect(await screen.findByText('Connection test failed')).toBeInTheDocument(); + expect(screen.getByText(/Unauthorized/)).toBeInTheDocument(); + }); + + it('surfaces the "Unable to test connection" alert when the test request rejects', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi.fn().mockRejectedValue(new Error('Network unreachable')); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + expect(await screen.findByText('Unable to test connection')).toBeInTheDocument(); + expect(screen.getByText(/Network unreachable/)).toBeInTheDocument(); + expect(screen.queryByText('Connection test passed')).not.toBeInTheDocument(); + expect(screen.queryByText('Connection test failed')).not.toBeInTheDocument(); + }); + + it('disables the submit button during the create mutation', async () => { + const create = vi + .fn() + .mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve({ id: 'conn-1', name: 'My inFakt Account' }), 100), + ), + ); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + const button = screen.getByRole('button', { name: /Connecting|Connect inFakt/ }); + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/apps/web/src/features/connections/components/infakt-setup-form.tsx b/apps/web/src/features/connections/components/infakt-setup-form.tsx new file mode 100644 index 000000000..a85753b08 --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup-form.tsx @@ -0,0 +1,194 @@ +/** + * Infakt Setup Form + * + * Single-step wizard for creating an inFakt connection. Collects: + * - Connection name + * - inFakt API key (the only credential) + * - Optional advanced base URL override (config) — sandbox vs. production + * + * Mirrors `ErliSetupForm`: one credential, no capabilities step (capabilities + * default silently to the adapter manifest's supported set on the omitted + * path). After a successful create the form surfaces a "Test connection" + * affordance that calls the generic `/connections/:id/test` endpoint and + * renders the `ConnectionTestResult`. Abandon-prevention triggers a native + * confirm dialog when the form is dirty and the tab is closed. + * + * @module features/connections/components + */ +import { useEffect, useState, type ReactElement } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { useCreateConnectionMutation } from '../hooks/use-create-connection-mutation'; +import { useTestConnectionMutation } from '../hooks/use-test-connection-mutation'; +import type { ConnectionTestResult } from '../api/connections.types'; +import { + INFAKT_SETUP_DEFAULT_VALUES, + infaktSetupSchema, + toCreateConnectionInput, + type InfaktSetupFormSubmission, + type InfaktSetupFormValues, +} from './infakt-setup.schema'; +import { Alert } from '../../../shared/ui/alert'; +import { BackLink } from '../../../shared/ui/back-link'; +import { Button } from '../../../shared/ui/button'; +import { FormErrorSummary } from '../../../shared/ui/form-error-summary'; +import { FormField } from '../../../shared/ui/form-field'; +import { Input } from '../../../shared/ui/input'; +import { useToast } from '../../../shared/ui/toast-provider'; + +export function InfaktSetupForm(): ReactElement { + const createConnection = useCreateConnectionMutation(); + const testConnection = useTestConnectionMutation(); + const { showToast } = useToast(); + const navigate = useNavigate(); + const [createdConnectionId, setCreatedConnectionId] = useState(null); + const [testResult, setTestResult] = useState(null); + + const form = useForm({ + defaultValues: INFAKT_SETUP_DEFAULT_VALUES, + resolver: zodResolver(infaktSetupSchema), + mode: 'onBlur', + }); + + // Abandon-prevention. + useEffect(() => { + function handleBeforeUnload(event: BeforeUnloadEvent): void { + if (!form.formState.isDirty || createdConnectionId !== null) return; + event.preventDefault(); + event.returnValue = ''; + } + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [form.formState.isDirty, createdConnectionId]); + + const validationMessages = Object.values(form.formState.errors).flatMap((error) => + error?.message ? [String(error.message)] : [], + ); + + const onSubmit = form.handleSubmit(async (values) => { + try { + const created = await createConnection.mutateAsync(toCreateConnectionInput(values)); + form.reset(values, { keepValues: true, keepDirty: false }); + setCreatedConnectionId(created.id); + showToast({ + tone: 'success', + title: 'Connection created', + description: `inFakt connection "${created.name}" was created.`, + }); + } catch { + return; + } + }); + + const onTest = async (): Promise => { + if (!createdConnectionId) return; + // Clear any prior result first: otherwise a re-test that rejects would render + // a stale resolved-result Alert alongside the "unable to test" error Alert. + setTestResult(null); + try { + const result = await testConnection.mutateAsync(createdConnectionId); + setTestResult(result); + } catch { + // surfaced via testConnection.error + } + }; + + return ( +
void onSubmit(event)} noValidate> + + + {form.formState.submitCount > 0 && validationMessages.length > 0 ? ( + + ) : null} + {createConnection.error ? ( + + {createConnection.error.message} + + ) : null} + + + In your inFakt account settings, generate an API key. OpenLinker uses + it to issue invoices and read KSeF clearance status through inFakt's native + integration. The key is stored securely on the server and only shown once. + + + + + + + + + + + + + + + {createdConnectionId ? ( + <> + {testResult ? ( + + {testResult.message} + {typeof testResult.latencyMs === 'number' ? ` (${testResult.latencyMs}ms)` : null} + + ) : null} + {testConnection.error ? ( + + {testConnection.error.message} + + ) : null} +
+ + +
+ + ) : ( +
+ +
+ )} + + ); +} diff --git a/apps/web/src/features/connections/components/infakt-setup.schema.ts b/apps/web/src/features/connections/components/infakt-setup.schema.ts new file mode 100644 index 000000000..86a630da6 --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup.schema.ts @@ -0,0 +1,61 @@ +/** + * Infakt Setup Form Schema + * + * Zod schema + form → API payload mapping for the guided inFakt connection + * wizard. inFakt is a Polish accounting platform authenticated with a single + * API key (#1280/#1282). The form collects a connection name, the required + * `apiKey` credential, and an optional advanced `baseUrl` config override + * (sandbox vs. production). Mirrors `erli-setup.schema.ts`. + * + * @module features/connections/components + */ +import { z } from 'zod'; +import type { CreateConnectionInput } from '../api/connections.types'; + +export const INFAKT_ADAPTER_KEY = 'infakt.accounting.v1'; + +// Mirrors the BE config DTO posture (optional https-only base URL override). +const isHttps = (value: string): boolean => value.startsWith('https://'); + +export const infaktSetupSchema = z.object({ + name: z.string().trim().min(1, 'Connection name is required'), + apiKey: z.string().trim().min(1, 'API key is required'), + baseUrl: z + .union([ + z + .string() + .trim() + .url('Base URL must be a valid URL (e.g. https://api.infakt.pl)') + .refine(isHttps, 'Base URL must use HTTPS'), + z.literal(''), + ]) + .optional(), +}); + +export type InfaktSetupFormValues = z.input; +export type InfaktSetupFormSubmission = z.output; + +export const INFAKT_SETUP_DEFAULT_VALUES: InfaktSetupFormValues = { + name: '', + apiKey: '', + baseUrl: '', +}; + +export function toCreateConnectionInput(values: InfaktSetupFormSubmission): CreateConnectionInput { + const config: Record = {}; + if (values.baseUrl && values.baseUrl.length > 0) { + config.baseUrl = values.baseUrl; + } + + return { + name: values.name, + platformType: 'infakt', + adapterKey: INFAKT_ADAPTER_KEY, + credentials: { apiKey: values.apiKey }, + config, + // enabledCapabilities is OMITTED on purpose: on the omitted path + // `ConnectionService.create` defaults to the adapter manifest's supported + // set, so the inFakt connection lands with the capabilities the + // registered `infakt.accounting.v1` adapter actually delivers (Invoicing). + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 944e63b02..c0a796db8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -9774,6 +9774,76 @@ html[data-density='compact'] .status-badge { align-self: stretch; } +/* ── Regulatory section card (#1282) ────────────────────────────────── + Tone wrapper a provider's `invoiceDetailSection` root class adopts + (KSeF, Subiekt, inFakt) so the region reads at a glance — severity + stripe reusing the existing --status-* tokens, no new colors. Additive + alongside each section's own root class (e.g. `invoice-detail-section`), + never a replacement, so pre-existing DOM-shape assertions keep passing. */ +.reg-card { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--border-subtle); + background: var(--bg-surface-muted); +} +.reg-card--info { + border-left-color: var(--status-info); + background: var(--status-info-soft); +} +.reg-card--success { + border-left-color: var(--status-success); + background: var(--status-success-soft); +} +.reg-card--error { + border-left-color: var(--status-error); + background: var(--status-error-soft); +} +.reg-card__header { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} +.reg-card__progress { + height: 3px; + border-radius: 2px; + background: linear-gradient( + 100deg, + var(--status-info-soft) 0%, + var(--status-info) 50%, + var(--status-info-soft) 100% + ); + background-size: 200% 100%; + animation: invoice-skeleton-shimmer 1.4s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .reg-card__progress { + animation: none; + background: var(--status-info); + } +} +.reg-card__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} +.reg-card__reference { + font-family: var(--font-mono); + font-size: 0.8125rem; +} +.reg-card__actions { + display: flex; + gap: var(--space-2); +} + /* ─ Invoice Timeline ─ */ .invoice-timeline { display: flex; @@ -9985,6 +10055,31 @@ html[data-density='compact'] .status-badge { margin-top: var(--space-3); } +/* ─ Infakt invoice correction dialog (#1282) ─ */ +.infakt-correction__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + border: 0; + padding: 0 0 var(--space-3); +} +.infakt-correction__reason { + margin-bottom: var(--space-4); +} +.infakt-correction__table-scroll { + overflow-x: auto; +} +.infakt-correction__col-qty { + min-width: 80px; +} +.infakt-correction__col-price { + min-width: 100px; +} +.infakt-correction__note { + margin-top: var(--space-3); +} + /* ─ Correction trigger in order invoice panel (issued state) ─ */ .order-invoice-panel__correction { margin-top: var(--space-3); diff --git a/apps/web/src/pages/connections/infakt-setup-page.tsx b/apps/web/src/pages/connections/infakt-setup-page.tsx new file mode 100644 index 000000000..670d0c165 --- /dev/null +++ b/apps/web/src/pages/connections/infakt-setup-page.tsx @@ -0,0 +1,26 @@ +/** + * Infakt Setup Page + * + * Page wrapper for the guided inFakt connection wizard. + */ +import type { ReactElement } from 'react'; +import { InfaktSetupForm } from '../../features/connections/components/infakt-setup-form'; +import { PageLayout } from '../../shared/ui/page-layout'; + +export function InfaktSetupPage(): ReactElement { + return ( + + API key + Guided setup + + } + > + + + ); +} diff --git a/apps/web/src/plugins/index.ts b/apps/web/src/plugins/index.ts index 13e50f807..1184d82bc 100644 --- a/apps/web/src/plugins/index.ts +++ b/apps/web/src/plugins/index.ts @@ -44,6 +44,7 @@ import { allegroPlugin } from './allegro'; import { assertUniquePluginInvariants } from './assert-unique-plugin-invariants'; import { dpdPlugin } from './dpd'; import { erliPlugin } from './erli'; +import { infaktPlugin } from './infakt'; import { inpostPlugin } from './inpost'; import { ksefPlugin } from './ksef'; import { prestashopPlugin } from './prestashop'; @@ -59,6 +60,7 @@ export const plugins: readonly OpenLinkerPlugin[] = [ erliPlugin, subiektPlugin, ksefPlugin, + infaktPlugin, ]; assertUniquePluginInvariants(plugins); diff --git a/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx new file mode 100644 index 000000000..dac952906 --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx @@ -0,0 +1,81 @@ +/** + * InfaktCredentialsPanel Tests + * + * Coverage for the rotate-API-key flow for inFakt connections. inFakt carries + * a single `apiKey` credential, so the panel rotates one field. Mirrors + * `erli-credentials-panel.test.tsx`. + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + findToastTitle, + renderWithProviders, + sampleConnection, +} from '../../../test/test-utils'; +import { InfaktCredentialsPanel } from './infakt-credentials-panel'; + +const infaktConnection = { ...sampleConnection, platformType: 'infakt' }; + +describe('InfaktCredentialsPanel', () => { + afterEach(cleanup); + + it('renders the rotate affordance for a credentials-backed connection', () => { + renderWithProviders(); + expect(screen.getByText('Rotate API key')).toBeInTheDocument(); + }); + + it('falls back to the env-var disabled input when credentialsBacked=false', () => { + const envBacked = { ...infaktConnection, credentialsBacked: false }; + renderWithProviders(); + expect( + screen.getByDisplayValue('Environment variable (not editable via UI)'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /rotate/i })).not.toBeInTheDocument(); + }); + + it('sends the new apiKey and surfaces a success toast', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const apiClient = createMockApiClient({ connections: { updateCredentials } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save new API key' })); + + await waitFor(() => { + expect(updateCredentials).toHaveBeenCalledWith(infaktConnection.id, { apiKey: 'sk_new_456' }); + }); + expect(await findToastTitle('Credentials rotated')).toBeInTheDocument(); + }); + + it('disables save while the field is empty', () => { + renderWithProviders(); + fireEvent.click(screen.getByText('Rotate API key')); + expect(screen.getByRole('button', { name: 'Save new API key' })).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + expect(screen.getByRole('button', { name: 'Save new API key' })).toBeEnabled(); + }); + + it('collapses the form after a successful save', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const apiClient = createMockApiClient({ connections: { updateCredentials } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save new API key' })); + + await waitFor(() => { + expect(screen.getByText('Rotate API key')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('New API key')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx new file mode 100644 index 000000000..6d419e79f --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx @@ -0,0 +1,102 @@ +/** + * Infakt Credentials Panel + * + * Plugin-owned credentials affordance for inFakt connections — rotates the + * stored API key in place via PUT /credentials. Owns its own toggle state, + * mutation, and toast feedback; the parent `EditConnectionForm` renders this + * only when the plugin contributes it. When the credential is env-backed + * (`credentialsBacked === false`) the panel shows a read-only affordance + * instead. Mirrors `ErliCredentialsPanel` (single `apiKey`, no auth-type + * enum). + * + * @module plugins/infakt/components + */ +import { useState, type FormEvent, type ReactElement } from 'react'; +import type { Connection } from '../../../features/connections'; +import { useUpdateConnectionCredentialsMutation } from '../../../features/connections'; +import { Alert } from '../../../shared/ui/alert'; +import { Button } from '../../../shared/ui/button'; +import { FormField } from '../../../shared/ui/form-field'; +import { Input } from '../../../shared/ui/input'; +import { useToast } from '../../../shared/ui/toast-provider'; + +export function InfaktCredentialsPanel({ connection }: { connection: Connection }): ReactElement { + const [showRotate, setShowRotate] = useState(false); + const [apiKey, setApiKey] = useState(''); + const rotate = useUpdateConnectionCredentialsMutation(); + const { showToast } = useToast(); + + if (!connection.credentialsBacked) { + return ( + + + + ); + } + + const canSubmit = apiKey.trim().length > 0; + + const onRotate = async (event: FormEvent): Promise => { + event.preventDefault(); + if (!canSubmit) return; + try { + await rotate.mutateAsync({ + connectionId: connection.id, + credentials: { apiKey: apiKey.trim() }, + }); + showToast({ + tone: 'success', + title: 'Credentials rotated', + description: 'The new inFakt API key is now in use.', + }); + setApiKey(''); + setShowRotate(false); + } catch { + // surfaced via rotate.error + } + }; + + return ( + + {showRotate ? ( +
+ {rotate.error ? {rotate.error.message} : null} + setApiKey(event.target.value)} + /> +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx new file mode 100644 index 000000000..78123481d --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx @@ -0,0 +1,222 @@ +/** + * InfaktInvoiceCorrectionFlow tests (#1282) + * + * @module plugins/infakt/components + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + renderWithProviders, + sampleConnection, +} from '../../../test/test-utils'; +import type { InvoiceRecord } from '../../../features/invoicing/api/invoicing.types'; +import { InfaktInvoiceCorrectionFlow } from './infakt-invoice-correction-flow'; + +const infaktConnection = { ...sampleConnection, platformType: 'infakt' }; + +function makeInvoice(overrides: Partial = {}): InvoiceRecord { + return { + id: 'ol_invoice_test', + connectionId: infaktConnection.id, + orderId: 'ol_order_test', + providerType: 'infakt', + documentType: 'invoice', + status: 'issued', + providerInvoiceId: 'infakt-101', + providerInvoiceNumber: 'FV 101/inFakt/2026', + regulatoryStatus: 'accepted', + clearanceReference: '5260001246-20260625-A1B2-3D', + pdfUrl: null, + failureMode: null, + failureCode: null, + failureReason: null, + issuedAt: '2026-06-25T10:00:00.000Z', + createdAt: '2026-06-25T09:59:00.000Z', + updatedAt: '2026-06-25T10:00:00.000Z', + ...overrides, + }; +} + +afterEach(() => cleanup()); + +describe('InfaktInvoiceCorrectionFlow', () => { + it('renders the form header and invoice reference', async () => { + renderWithProviders( + , + ); + expect(await screen.findByRole('heading', { name: /Issue KOR correction/i })).toBeDefined(); + expect(await screen.findByText(/FV 101\/inFakt\/2026/i)).toBeDefined(); + }); + + it('renders the reason textarea', async () => { + renderWithProviders( + , + ); + expect(await screen.findByLabelText(/Reason for correction/i)).toBeDefined(); + }); + + it('starts with one empty line row', async () => { + renderWithProviders( + , + ); + const lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(1); + }); + + it('adds a line row on "+ Add line" click', async () => { + renderWithProviders( + , + ); + const addBtn = await screen.findByText(/Add line/i); + fireEvent.click(addBtn); + const lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(2); + }); + + it('removes a line row when Remove is clicked', async () => { + renderWithProviders( + , + ); + fireEvent.click(await screen.findByText(/Add line/i)); + let lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(2); + + const removeBtns = await screen.findAllByRole('button', { name: /Remove line/i }); + fireEvent.click(removeBtns[0]); + + lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(1); + }); + + it('calls onClose when Cancel is clicked', async () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + fireEvent.click(await screen.findByText(/Cancel/i)); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('blocks submit when no line has a line number and shows error', async () => { + const issueCorrection = vi.fn(); + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + expect(await screen.findByRole('alert')).toBeDefined(); + expect(await screen.findByText(/at least one line/i)).toBeDefined(); + expect(issueCorrection).not.toHaveBeenCalled(); + }); + + it('submits issueCorrection with reason, line data and idempotencyKey on form submit', async () => { + const correctionInvoice = makeInvoice({ id: 'ol_invoice_correction' }); + const issueCorrection = vi.fn().mockResolvedValue(correctionInvoice); + const onCorrectionIssued = vi.fn(); + const onClose = vi.fn(); + + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + const reasonInput = await screen.findByLabelText(/Reason for correction/i); + fireEvent.change(reasonInput, { target: { value: 'Partial return' } }); + + const lineInput = await screen.findByLabelText(/Line number 1/i); + fireEvent.change(lineInput, { target: { value: '1' } }); + const qtyInput = await screen.findByLabelText(/New qty, line 1/i); + fireEvent.change(qtyInput, { target: { value: '0' } }); + const priceInput = await screen.findByLabelText(/New price, line 1/i); + fireEvent.change(priceInput, { target: { value: '34.84' } }); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + await waitFor(() => { + expect(issueCorrection).toHaveBeenCalledWith( + 'ol_invoice_test', + expect.objectContaining({ + reason: 'Partial return', + lines: [{ originalLineNumber: 1, newQuantity: 0, newUnitPriceGross: 34.84 }], + idempotencyKey: expect.stringMatching(/^infakt-corr-/), + }), + ); + }); + + await waitFor(() => expect(onCorrectionIssued).toHaveBeenCalledWith('ol_invoice_correction')); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + }); + + it('does not include empty line rows in the submission', async () => { + const issueCorrection = vi.fn().mockResolvedValue(makeInvoice({ id: 'ol_invoice_cor2' })); + + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + fireEvent.click(await screen.findByText(/Add line/i)); + + const lineInputs = await screen.findAllByLabelText(/Line number/i); + fireEvent.change(lineInputs[0], { target: { value: '2' } }); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + await waitFor(() => { + const call = issueCorrection.mock.calls[0] as [ + string, + { lines: { originalLineNumber: number }[] }, + ]; + expect(call[1].lines).toHaveLength(1); + expect(call[1].lines[0].originalLineNumber).toBe(2); + }); + }); +}); diff --git a/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx new file mode 100644 index 000000000..c64b36256 --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx @@ -0,0 +1,289 @@ +/** + * InfaktInvoiceCorrectionFlow (#1282) + * + * Per-provider `invoiceCorrectionFlow` slot for inFakt. Issues a KOR (faktura + * korygująca) via `POST /invoices/:invoiceId/correct` — the same generic + * capability-gated endpoint KSeF uses (backed by `InfaktInvoicingAdapter` + * implementing `CorrectionIssuer`, PR #1292). The operator supplies: + * - an optional free-text reason; + * - per-line new quantity and/or new unit price gross. + * + * The form starts with ONE empty row and an "Add line" affordance for + * multi-line corrections. Near-1:1 port of `KsefInvoiceCorrectionFlow` — same + * line-row model, same generic `useIssueCorrectionMutation` hook, same + * `InvoiceCorrectionFlowProps` slot contract. The host dialog owns the outer + * chrome; this component is content-only — call `onClose` to close the + * dialog. + * + * @module plugins/infakt/components + */ +import { type ReactElement, useRef, useState } from 'react'; +import { useTranslation } from '../../../shared/i18n'; +import { Button } from '../../../shared/ui/button'; +import { useToast } from '../../../shared/ui/toast-provider'; +import type { InvoiceCorrectionFlowProps } from '../../../shared/plugins/plugin.types'; +import { + useIssueCorrectionMutation, + type CorrectionLineInput, +} from '../../../features/invoicing'; + +interface LineRow { + originalLineNumber: string; + newQuantity: string; + newUnitPriceGross: string; +} + +function emptyRow(): LineRow { + return { originalLineNumber: '', newQuantity: '', newUnitPriceGross: '' }; +} + +function parseLineRows(rows: LineRow[]): CorrectionLineInput[] { + return rows + .filter((r) => r.originalLineNumber.trim() !== '') + .map((r) => { + const lineNum = parseInt(r.originalLineNumber, 10); + const qty = r.newQuantity.trim() !== '' ? parseFloat(r.newQuantity) : undefined; + const price = + r.newUnitPriceGross.trim() !== '' ? parseFloat(r.newUnitPriceGross) : undefined; + return { + originalLineNumber: lineNum, + ...(qty !== undefined && !Number.isNaN(qty) ? { newQuantity: qty } : {}), + ...(price !== undefined && !Number.isNaN(price) ? { newUnitPriceGross: price } : {}), + }; + }); +} + +export function InfaktInvoiceCorrectionFlow({ + invoice, + onClose, + onCorrectionIssued, +}: InvoiceCorrectionFlowProps): ReactElement { + const { t } = useTranslation(); + const { showToast } = useToast(); + const mutation = useIssueCorrectionMutation(); + + // Per-mount stable idempotency key — prevents duplicate KOR issuance on timeout/retry. + const idempotencyKeyRef = useRef( + `infakt-corr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + ); + + const [reason, setReason] = useState(''); + const [lines, setLines] = useState([emptyRow()]); + const [linesError, setLinesError] = useState(null); + + function updateLine(index: number, field: keyof LineRow, value: string): void { + setLines((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); + } + + function addLine(): void { + setLines((prev) => [...prev, emptyRow()]); + } + + function removeLine(index: number): void { + setLines((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); + } + + function handleSubmit(): void { + const parsedLines = parseLineRows(lines); + if (parsedLines.length === 0) { + setLinesError( + t( + 'infakt.correction.linesRequired', + 'At least one line with a line number is required.', + ), + ); + return; + } + setLinesError(null); + mutation.mutate( + { + invoiceId: invoice.id, + input: { + reason: reason.trim() !== '' ? reason.trim() : undefined, + lines: parsedLines, + idempotencyKey: idempotencyKeyRef.current, + }, + }, + { + onSuccess: (correctionInvoice) => { + showToast({ + tone: 'success', + title: t('infakt.correction.issued', 'KOR issued'), + description: t( + 'infakt.correction.issuedBody', + 'The correcting document was submitted to KSeF via inFakt.', + ), + }); + onCorrectionIssued(correctionInvoice.id); + onClose(); + }, + onError: (error) => { + showToast({ + tone: 'error', + title: t('infakt.correction.failed', 'KOR failed'), + description: error.message, + }); + }, + }, + ); + } + + const isSubmitting = mutation.isPending; + + return ( +
+ {/* Header */} +
+

{t('infakt.correction.title', 'Issue KOR correction')}

+ + {t('infakt.correction.providerTag', 'inFakt · slot')} + +
+ + {/* Original invoice reference */} +
+ + {t('infakt.correction.correcting', 'Correcting')}{' '} + {invoice.providerInvoiceNumber ?? invoice.id} + + {invoice.clearanceReference ? ( + + {t('infakt.correction.ksefRef', 'KSeF')}{' '} + + {invoice.clearanceReference.slice(0, 14)}… + + + ) : null} + {invoice.issuedAt ? ( + + {t('infakt.correction.issuedOn', 'Issued')}{' '} + + {invoice.issuedAt.slice(0, 10)} + + + ) : null} +
+ + {/* Reason */} +
+ +