From 5af765f9576a96f3060bc8381efa17302e7c38b9 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 13:20:14 +0200 Subject: [PATCH 1/5] feat(integrations): production-harden Infakt plugin - validators, retry classifier, NestJS module, tests Config/credentials shape validators, fiscal-safety retry classifier (4xx->rejected, 429->transient, 5xx/network->in-doubt), and the NestJS module via createNestAdapterModule, wired into register(). Adds unit test coverage for the adapter, HTTP client, webhook translator, factory, and both validators. Closes #1280 Signed-off-by: norbert-kulus-blockydevs --- libs/integrations/infakt/jest.config.mjs | 30 ++ libs/integrations/infakt/package.json | 50 ++ .../__tests__/infakt-adapter.factory.spec.ts | 112 +++++ .../src/application/infakt-adapter.factory.ts | 46 ++ .../src/domain/exceptions/infakt-api.error.ts | 44 ++ .../infakt/src/domain/types/infakt.types.ts | 119 +++++ libs/integrations/infakt/src/index.ts | 17 + .../infakt/src/infakt-integration.module.ts | 22 + libs/integrations/infakt/src/infakt-plugin.ts | 86 ++++ ...ion-config-shape-validator.adapter.spec.ts | 59 +++ ...redentials-shape-validator.adapter.spec.ts | 47 ++ .../infakt-invoicing.adapter.spec.ts | 459 ++++++++++++++++++ .../infakt-retry-classifier.adapter.spec.ts | 59 +++ ...nnection-config-shape-validator.adapter.ts | 54 +++ ...ion-credentials-shape-validator.adapter.ts | 42 ++ .../adapters/infakt-invoicing.adapter.ts | 363 ++++++++++++++ .../infakt-retry-classifier.adapter.ts | 65 +++ .../http/__tests__/infakt-http-client.spec.ts | 155 ++++++ .../http/infakt-http-client.interface.ts | 18 + .../infrastructure/http/infakt-http-client.ts | 80 +++ .../infakt-webhook-translator.spec.ts | 130 +++++ .../webhooks/infakt-webhook-translator.ts | 184 +++++++ .../infakt/src/scripts/poc-sandbox-test.ts | 254 ++++++++++ .../src/testing/fake-infakt-http-client.ts | 68 +++ libs/integrations/infakt/tsconfig.json | 18 + libs/integrations/infakt/tsconfig.spec.json | 7 + pnpm-lock.yaml | 40 ++ 27 files changed, 2628 insertions(+) create mode 100644 libs/integrations/infakt/jest.config.mjs create mode 100644 libs/integrations/infakt/package.json create mode 100644 libs/integrations/infakt/src/application/__tests__/infakt-adapter.factory.spec.ts create mode 100644 libs/integrations/infakt/src/application/infakt-adapter.factory.ts create mode 100644 libs/integrations/infakt/src/domain/exceptions/infakt-api.error.ts create mode 100644 libs/integrations/infakt/src/domain/types/infakt.types.ts create mode 100644 libs/integrations/infakt/src/index.ts create mode 100644 libs/integrations/infakt/src/infakt-integration.module.ts create mode 100644 libs/integrations/infakt/src/infakt-plugin.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-config-shape-validator.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-credentials-shape-validator.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-retry-classifier.adapter.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-config-shape-validator.adapter.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts create mode 100644 libs/integrations/infakt/src/infrastructure/adapters/infakt-retry-classifier.adapter.ts create mode 100644 libs/integrations/infakt/src/infrastructure/http/__tests__/infakt-http-client.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/http/infakt-http-client.interface.ts create mode 100644 libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts create mode 100644 libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts create mode 100644 libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts create mode 100644 libs/integrations/infakt/src/scripts/poc-sandbox-test.ts create mode 100644 libs/integrations/infakt/src/testing/fake-infakt-http-client.ts create mode 100644 libs/integrations/infakt/tsconfig.json create mode 100644 libs/integrations/infakt/tsconfig.spec.json diff --git a/libs/integrations/infakt/jest.config.mjs b/libs/integrations/infakt/jest.config.mjs new file mode 100644 index 000000000..56fc6e05b --- /dev/null +++ b/libs/integrations/infakt/jest.config.mjs @@ -0,0 +1,30 @@ +export default { + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/src/**/*.spec.ts'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@openlinker/integrations-infakt$': '/src/index.ts', + '^@openlinker/integrations-infakt/(.*)$': '/src/$1', + '^@openlinker/core$': '/../../core/src/index.ts', + '^@openlinker/core/(.*)$': '/../../core/src/$1', + '^@openlinker/shared$': '/../../shared/src/index.ts', + '^@openlinker/shared/(.*)$': '/../../shared/src/$1', + '^@openlinker/plugin-sdk$': '/../../plugin-sdk/src/index.ts', + }, + moduleFileExtensions: ['ts', 'js', 'json'], + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '/coverage', + clearMocks: true, + testTimeout: 30000, +}; diff --git a/libs/integrations/infakt/package.json b/libs/integrations/infakt/package.json new file mode 100644 index 000000000..55332f724 --- /dev/null +++ b/libs/integrations/infakt/package.json @@ -0,0 +1,50 @@ +{ + "name": "@openlinker/integrations-infakt", + "version": "0.1.0", + "description": "Infakt SaaS accounting adapter for OpenLinker (invoicing + KSeF via Infakt)", + "license": "Apache-2.0", + "author": "OpenLinker", + "private": true, + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "jest --config ./jest.config.mjs", + "test:watch": "jest --watch --config ./jest.config.mjs", + "test:cov": "jest --coverage --config ./jest.config.mjs", + "lint": "eslint \"src/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "poc:sandbox": "ts-node -r tsconfig-paths/register src/scripts/poc-sandbox-test.ts" + }, + "dependencies": { + "@openlinker/core": "workspace:*", + "@openlinker/plugin-sdk": "workspace:*", + "@openlinker/shared": "workspace:*" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@types/jest": "29.5.12", + "@types/node": "20.11.30", + "@typescript-eslint/eslint-plugin": "7.3.1", + "@typescript-eslint/parser": "7.3.1", + "eslint": "8.57.0", + "jest": "29.7.0", + "ts-jest": "29.1.2", + "typescript": "5.4.3" + } +} diff --git a/libs/integrations/infakt/src/application/__tests__/infakt-adapter.factory.spec.ts b/libs/integrations/infakt/src/application/__tests__/infakt-adapter.factory.spec.ts new file mode 100644 index 000000000..de5f63842 --- /dev/null +++ b/libs/integrations/infakt/src/application/__tests__/infakt-adapter.factory.spec.ts @@ -0,0 +1,112 @@ +/** + * Infakt Adapter Factory — unit tests + * + * Verifies per-connection credential resolution and the sandbox-vs-production + * `baseUrl` resolution. Stubs `global.fetch` to assert the constructed + * `InfaktInvoicingAdapter`'s HTTP client hits the resolved base URL with the + * resolved API key. + * + * @module libs/integrations/infakt/src/application/__tests__ + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import type { CredentialsResolverPort } from '@openlinker/core/integrations'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import { INFAKT_DEFAULT_BASE_URL } from '../../infrastructure/http/infakt-http-client'; +import { InfaktAdapterFactory } from '../infakt-adapter.factory'; + +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, + } as Connection; +} + +function resolverFor(credentials: unknown): CredentialsResolverPort { + return { get: jest.fn().mockResolvedValue(credentials) }; +} + +function fakeLogger(): jest.Mocked { + return { log: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +function okResponse(): Response { + return { + ok: true, + status: 200, + text: (): Promise => Promise.resolve('{}'), + } as unknown as Response; +} + +const originalFetch = global.fetch; + +describe('InfaktAdapterFactory', () => { + let factory: InfaktAdapterFactory; + let fetchMock: jest.Mock; + let logger: jest.Mocked; + + beforeEach(() => { + factory = new InfaktAdapterFactory(); + fetchMock = jest.fn().mockResolvedValue(okResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + logger = fakeLogger(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + type FetchCall = [url: string, init: { headers: Record }]; + function lastFetchCall(): FetchCall { + const calls = fetchMock.mock.calls as FetchCall[]; + return calls[calls.length - 1]; + } + + it('should resolve the apiKey from the credentials resolver via connection.credentialsRef', async () => { + const resolver = resolverFor({ apiKey: 'resolved-key' }); + await factory.createInvoicingAdapter(connection(), resolver, logger); + + expect(resolver.get).toHaveBeenCalledWith('ref-1'); + }); + + it('should default to INFAKT_DEFAULT_BASE_URL when connection.config has no baseUrl', async () => { + const resolver = resolverFor({ apiKey: 'k' }); + const adapter = await factory.createInvoicingAdapter(connection(), resolver, logger); + + await adapter.getInvoice({ providerInvoiceId: 'inv-1' }).catch(() => undefined); + + const [url, init] = lastFetchCall(); + expect(url).toContain(INFAKT_DEFAULT_BASE_URL); + expect(init.headers['X-inFakt-ApiKey']).toBe('k'); + }); + + it('should use the sandbox baseUrl override from connection.config when present', async () => { + const resolver = resolverFor({ apiKey: 'k' }); + const sandboxUrl = 'https://api.sandbox.infakt.pl/api/v3'; + const adapter = await factory.createInvoicingAdapter( + connection({ config: { baseUrl: sandboxUrl } }), + resolver, + logger, + ); + + await adapter.getInvoice({ providerInvoiceId: 'inv-1' }).catch(() => undefined); + + const [url] = lastFetchCall(); + expect(url).toContain(sandboxUrl); + }); + + it('should throw when the connection has no credentialsRef', async () => { + const resolver = resolverFor({ apiKey: 'k' }); + await expect( + factory.createInvoicingAdapter(connection({ credentialsRef: '' }), resolver, logger), + ).rejects.toThrow(/no credentialsRef/); + }); +}); diff --git a/libs/integrations/infakt/src/application/infakt-adapter.factory.ts b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts new file mode 100644 index 000000000..1b3f1e2e6 --- /dev/null +++ b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts @@ -0,0 +1,46 @@ +/** + * Infakt Adapter Factory + * + * Resolves credentials from the host secrets store and constructs an + * `InfaktInvoicingAdapter` bound to a specific connection. + * + * @module libs/integrations/infakt/src/application + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import type { CredentialsResolverPort } from '@openlinker/core/integrations'; +import { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from '../infrastructure/http/infakt-http-client'; +import { InfaktInvoicingAdapter } from '../infrastructure/adapters/infakt-invoicing.adapter'; + +interface InfaktCredentials { + apiKey: string; +} + +interface InfaktConnectionConfig { + baseUrl?: string; +} + +export class InfaktAdapterFactory { + async createInvoicingAdapter( + connection: Connection, + credentialsResolver: CredentialsResolverPort, + logger: LoggerPort, + ): Promise { + let apiKey: string; + if (connection.credentialsRef) { + const raw = await credentialsResolver.get(connection.credentialsRef); + const creds = raw as InfaktCredentials; + apiKey = creds.apiKey; + } else { + throw new Error(`Infakt connection ${connection.id} has no credentialsRef`); + } + + const config = (connection.config ?? {}) as InfaktConnectionConfig; + const httpClient = new InfaktHttpClient( + { apiKey, baseUrl: config.baseUrl ?? INFAKT_DEFAULT_BASE_URL }, + logger, + ); + + return new InfaktInvoicingAdapter(connection.id, httpClient, logger); + } +} diff --git a/libs/integrations/infakt/src/domain/exceptions/infakt-api.error.ts b/libs/integrations/infakt/src/domain/exceptions/infakt-api.error.ts new file mode 100644 index 000000000..d2e14c5e7 --- /dev/null +++ b/libs/integrations/infakt/src/domain/exceptions/infakt-api.error.ts @@ -0,0 +1,44 @@ +/** + * InfaktApiError — thrown by InfaktHttpClient on non-2xx responses. + * + * `statusCode` is the HTTP status; `responseBody` is the raw parsed response + * (or raw text when the response wasn't JSON). + * + * Carries the neutral `failureMode` discriminator core's `InvoiceService` + * reads STRUCTURALLY (#1200) to decide fiscal re-attemptability — core never + * value-imports this class: + * - `4xx` except `429` -> `'rejected'`: Infakt DEFINITELY did not create a + * document (deterministic rejection) — SAFE to re-attempt. + * - `429` or `5xx` -> `'in-doubt'`: rate-limited or a server-side failure; + * the document MAY already exist (or be mid-flight) — UNSAFE to + * auto-re-attempt. Mirrors `SubiektBridgeTransportError`'s retryability + * pivot. + * + * @module libs/integrations/infakt/src/domain/exceptions + */ +export class InfaktApiError extends Error { + /** + * Neutral failure discriminator the core `InvoiceService` reads + * STRUCTURALLY (#1200) to decide re-attemptability. + */ + readonly failureMode: 'rejected' | 'in-doubt'; + + constructor( + message: string, + public readonly statusCode: number, + public readonly responseBody: unknown, + ) { + super(message); + this.name = 'InfaktApiError'; + this.failureMode = this.isClientError() && statusCode !== 429 ? 'rejected' : 'in-doubt'; + Error.captureStackTrace(this, this.constructor); + } + + isClientError(): boolean { + return this.statusCode >= 400 && this.statusCode < 500; + } + + isServerError(): boolean { + return this.statusCode >= 500; + } +} diff --git a/libs/integrations/infakt/src/domain/types/infakt.types.ts b/libs/integrations/infakt/src/domain/types/infakt.types.ts new file mode 100644 index 000000000..c80bd7785 --- /dev/null +++ b/libs/integrations/infakt/src/domain/types/infakt.types.ts @@ -0,0 +1,119 @@ +/** + * Infakt API v3 wire types (PL-specific shapes). + * + * Only the fields OL actually reads are declared; the full Infakt schema is + * richer. All PL-specific vocabulary (nip, ksef, paragon, etc.) lives here, + * never in libs/core. + * + * @module libs/integrations/infakt/src/domain/types + */ + +/** Infakt KSeF status values as returned in `ksef_data.status`. */ +export const InfaktKsefStatusValues = [ + 'pending', + 'sent', + 'success', + 'error', +] as const; +export type InfaktKsefStatus = (typeof InfaktKsefStatusValues)[number]; + +/** Partial KSeF data block on an invoice response. */ +export interface InfaktKsefData { + request_uuid: string | null; + ksef_number: string | null; + status: InfaktKsefStatus; + status_description: string | null; + timestamps: { + request_created_at: string | null; + request_finished_at: string | null; + } | null; +} + +/** Infakt invoice kinds. */ +export type InfaktInvoiceKind = + | 'vat' + | 'corrective' + | 'advance' + | 'final' + | 'internal' + | 'margin' + | 'oss' + | 'corrective_oss' + | 'proforma'; + +/** Invoice from GET /invoices/{uuid}.json */ +export interface InfaktInvoice { + uuid: string; + number: string | null; + kind: InfaktInvoiceKind; + status: string; + gross_price: number; + net_price: number; + tax_price: number; + payment_method: string; + invoice_date: string | null; + sale_date: string | null; + due_date: string | null; + paid_date: string | null; + corrected_invoice_number: string | null; + correction_reason: string | null; + correction_reason_symbol: string | null; + ksef_number: string | null; + ksef_data: InfaktKsefData | null; + client_id: number | null; + client_uuid: string | null; + services: InfaktInvoiceService[]; + print_url: string | null; + pdf_url: string | null; +} + +/** One line item on an Infakt invoice. */ +export interface InfaktInvoiceService { + name: string; + tax_symbol: string; + quantity: number; + unit: string | null; + unit_net_price: number; + net_price: number; + tax_price: number; + gross_price: number; + correction: boolean | null; + group: number | null; +} + +/** Client (buyer) from GET /clients/{uuid}.json or POST /clients.json */ +export interface InfaktClient { + id: number; + uuid: string; + name: string; + nip: string | null; + email: string | null; + city: string | null; + street: string | null; + post_code: string | null; + country: string | null; +} + +/** Paginated list response shape. */ +export interface InfaktListResponse { + entities: T[]; + metainfo: { + total_count: number; + next: string | null; + previous: string | null; + }; +} + +/** Response from POST /invoices/{uuid}/send_to_ksef.json */ +export interface InfaktSendToKsefResponse { + request_uuid: string; + invoice_uuid: string; + invoice_kind: string; + ksef_number: string | null; + status: InfaktKsefStatus; + status_description: string | null; + timestamps: { + request_created_at: string | null; + request_finished_at: string | null; + }; +} diff --git a/libs/integrations/infakt/src/index.ts b/libs/integrations/infakt/src/index.ts new file mode 100644 index 000000000..974dfcf3d --- /dev/null +++ b/libs/integrations/infakt/src/index.ts @@ -0,0 +1,17 @@ +export { createInfaktPlugin, infaktAdapterManifest } from './infakt-plugin'; + +// Host wiring — createNestAdapterModule bridge (#1280). +export { InfaktIntegrationModule } from './infakt-integration.module'; + +export { InfaktWebhookTranslator } from './infrastructure/webhooks/infakt-webhook-translator'; +export type { InfaktWebhookEvent, InfaktWebhookEventName, InfaktWebhookTranslatorConfig } from './infrastructure/webhooks/infakt-webhook-translator'; +export { InfaktInvoicingAdapter, INFAKT_PROVIDER_TYPE } from './infrastructure/adapters/infakt-invoicing.adapter'; +export { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from './infrastructure/http/infakt-http-client'; +export { InfaktApiError } from './domain/exceptions/infakt-api.error'; +export type { InfaktInvoice, InfaktClient, InfaktKsefData, InfaktKsefStatus } from './domain/types/infakt.types'; + +// Shape validators + retry classifier — exported so host-side tests can +// register the real adapters (mirrors the KSeF precedent). +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'; diff --git a/libs/integrations/infakt/src/infakt-integration.module.ts b/libs/integrations/infakt/src/infakt-integration.module.ts new file mode 100644 index 000000000..2bf31b847 --- /dev/null +++ b/libs/integrations/infakt/src/infakt-integration.module.ts @@ -0,0 +1,22 @@ +/** + * Infakt Integration Module + * + * Host wiring for the Infakt Accounting API v3 plugin. Infakt has no + * plugin-specific NestJS providers, so it uses the SDK's + * `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). + * + * Not yet wired into `apps/api/src/plugins.ts` / `apps/worker/src/plugins.ts` + * — that registration + webhook routing is #1281. + * + * @module libs/integrations/infakt/src + */ +import type { DynamicModule } from '@nestjs/common'; +import { createNestAdapterModule } from '@openlinker/plugin-sdk'; +import { createInfaktPlugin } from './infakt-plugin'; + +export const InfaktIntegrationModule: DynamicModule = createNestAdapterModule({ + plugin: createInfaktPlugin(), +}); diff --git a/libs/integrations/infakt/src/infakt-plugin.ts b/libs/integrations/infakt/src/infakt-plugin.ts new file mode 100644 index 000000000..120811073 --- /dev/null +++ b/libs/integrations/infakt/src/infakt-plugin.ts @@ -0,0 +1,86 @@ +/** + * Infakt Plugin Descriptor + * + * Framework-neutral `AdapterPlugin` for the Infakt SaaS accounting integration. + * Capability: `'Invoicing'` — implements `InvoicingPort`, `RegulatoryStatusReader`, + * and `CorrectionIssuer`. `supportedCapabilities` lists only `Invoicing` — the + * two sub-capabilities are narrowed via type guards at call sites, not + * declared separately in the manifest (mirrors KSeF). + * + * KSeF model: Infakt submits to KSeF natively. OL does not build FA(3) XML. + * This is why the adapter implements `RegulatoryStatusReader` (read clearance + * status) rather than `RegulatoryTransmitter` (active KSeF session). + * + * Side-registrations land in `register(host)` (`createNestAdapterModule` + * invokes it): the connection config + credentials shape validators and the + * retry classifier, so malformed connections are rejected before persistence + * and terminal/in-doubt Infakt failures aren't blindly retried by the worker + * runner (see `InfaktRetryClassifierAdapter` for the fiscal-safety reasoning). + * + * @module libs/integrations/infakt/src + */ +import { dispatchCapability, type AdapterPlugin, type HostServices } from '@openlinker/plugin-sdk'; +import type { AdapterMetadata } from '@openlinker/core/integrations'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import { Logger } from '@openlinker/shared/logging'; +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'; + +/** + * Static plugin manifest. Exported for host tooling (capability-matrix, manifest + * diff); `createInfaktPlugin().manifest` returns this same reference. + */ +export const infaktAdapterManifest: AdapterMetadata = { + adapterKey: 'infakt.accounting.v1', + platformType: 'infakt', + supportedCapabilities: ['Invoicing'], + displayName: 'Infakt Accounting API v3', + version: '1.0.0', + isDefault: true, +}; + +const INFAKT_BRAND = 'Infakt'; +const INFAKT_ADAPTER_KEY = infaktAdapterManifest.adapterKey; + +export function createInfaktPlugin(): AdapterPlugin { + return { + manifest: infaktAdapterManifest, + + register(host: HostServices): void { + host.connectionConfigShapeValidatorRegistry.register( + INFAKT_ADAPTER_KEY, + new InfaktConnectionConfigShapeValidatorAdapter(INFAKT_BRAND), + ); + host.connectionCredentialsShapeValidatorRegistry.register( + INFAKT_ADAPTER_KEY, + new InfaktConnectionCredentialsShapeValidatorAdapter(INFAKT_BRAND), + ); + host.retryClassifierRegistry.register(INFAKT_ADAPTER_KEY, new InfaktRetryClassifierAdapter()); + }, + + async createCapabilityAdapter( + connection: Connection, + capability: string, + host: HostServices, + ): Promise { + try { + const logger = new Logger(`Infakt:${connection.id}`); + const factory = new InfaktAdapterFactory(); + const invoicingAdapter = await factory.createInvoicingAdapter( + connection, + host.credentialsResolver, + logger, + ); + return dispatchCapability( + capability, + { Invoicing: () => invoicingAdapter }, + INFAKT_BRAND, + ); + } catch (err) { + return Promise.reject(err as Error); + } + }, + }; +} diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-config-shape-validator.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-config-shape-validator.adapter.spec.ts new file mode 100644 index 000000000..2447def07 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-config-shape-validator.adapter.spec.ts @@ -0,0 +1,59 @@ +/** + * Infakt Connection Config Shape Validator — unit tests + * + * Verifies the optional `baseUrl` URL check and the flat-issue rejection + * payload. + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import { InvalidConnectionConfigException } from '@openlinker/core/integrations'; +import { InfaktConnectionConfigShapeValidatorAdapter } from '../infakt-connection-config-shape-validator.adapter'; + +describe('InfaktConnectionConfigShapeValidatorAdapter', () => { + const validator = new InfaktConnectionConfigShapeValidatorAdapter(); + + it('should resolve when config is empty (baseUrl is optional)', async () => { + await expect(validator.validate({})).resolves.toBeUndefined(); + }); + + it('should resolve when baseUrl is a valid URL', async () => { + await expect( + validator.validate({ baseUrl: 'https://api.sandbox.infakt.pl/api/v3' }), + ).resolves.toBeUndefined(); + }); + + it('should resolve when baseUrl is null', async () => { + await expect(validator.validate({ baseUrl: null })).resolves.toBeUndefined(); + }); + + it('should reject when baseUrl is not a valid URL', async () => { + await expect(validator.validate({ baseUrl: 'not-a-url' })).rejects.toBeInstanceOf( + InvalidConnectionConfigException, + ); + }); + + it('should reject when baseUrl is an empty string', async () => { + await expect(validator.validate({ baseUrl: '' })).rejects.toBeInstanceOf( + InvalidConnectionConfigException, + ); + }); + + it('should reject when baseUrl is whitespace-only', async () => { + await expect(validator.validate({ baseUrl: ' ' })).rejects.toBeInstanceOf( + InvalidConnectionConfigException, + ); + }); + + it('should reject when baseUrl is not a string', async () => { + await expect(validator.validate({ baseUrl: 123 })).rejects.toBeInstanceOf( + InvalidConnectionConfigException, + ); + }); + + it('should carry a flat { path, message } issue for baseUrl', async () => { + await expect(validator.validate({ baseUrl: 'not-a-url' })).rejects.toMatchObject({ + pluginName: 'Infakt', + errors: [{ path: 'baseUrl', message: expect.stringContaining('valid URL') }], + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-credentials-shape-validator.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-credentials-shape-validator.adapter.spec.ts new file mode 100644 index 000000000..7ad947c44 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-connection-credentials-shape-validator.adapter.spec.ts @@ -0,0 +1,47 @@ +/** + * Infakt Connection Credentials Shape Validator — unit tests + * + * Verifies the required, non-empty `apiKey` string check and that the + * error detail never echoes the credential value. + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import { InvalidCredentialsShapeException } from '@openlinker/core/integrations'; +import { InfaktConnectionCredentialsShapeValidatorAdapter } from '../infakt-connection-credentials-shape-validator.adapter'; + +describe('InfaktConnectionCredentialsShapeValidatorAdapter', () => { + const validator = new InfaktConnectionCredentialsShapeValidatorAdapter(); + + it('should resolve when apiKey is a non-empty string', async () => { + await expect(validator.validate({ apiKey: 'sk_live_abc123' })).resolves.toBeUndefined(); + }); + + it('should reject when apiKey is missing', async () => { + await expect(validator.validate({})).rejects.toBeInstanceOf(InvalidCredentialsShapeException); + }); + + it('should reject when apiKey is an empty string', async () => { + await expect(validator.validate({ apiKey: '' })).rejects.toBeInstanceOf( + InvalidCredentialsShapeException, + ); + }); + + it('should reject when apiKey is whitespace-only', async () => { + await expect(validator.validate({ apiKey: ' ' })).rejects.toBeInstanceOf( + InvalidCredentialsShapeException, + ); + }); + + it('should reject when apiKey is not a string', async () => { + await expect(validator.validate({ apiKey: 12345 })).rejects.toBeInstanceOf( + InvalidCredentialsShapeException, + ); + }); + + it('should never echo the apiKey value in the rejection message', async () => { + const secret = 'sk_super_secret_value'; + await expect(validator.validate({ apiKey: '' })).rejects.toMatchObject({ + message: expect.not.stringContaining(secret), + }); + }); +}); 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 new file mode 100644 index 000000000..71d9acf29 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-invoicing.adapter.spec.ts @@ -0,0 +1,459 @@ +/** + * Infakt Invoicing Adapter — unit tests + * + * Happy-path + error-path coverage per `InvoicingPort` / `RegulatoryStatusReader` + * / `CorrectionIssuer` method, using `FakeInfaktHttpClient` in place of a real + * `fetch`. Pins the fiscal-safety-critical cases: a 422 rejection propagates + * with `failureMode: 'rejected'`, a 500 propagates with `failureMode: + * 'in-doubt'`, and `getClearanceStatus` maps every `ksef_status` value onto + * the neutral `RegulatoryStatus`. + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import { BuyerProfile, InvoiceRecord } from '@openlinker/core/invoicing'; +import type { IssueInvoiceCommand, IssueCorrectionCommand } from '@openlinker/core/invoicing'; +import { InfaktInvoicingAdapter, INFAKT_PROVIDER_TYPE } from '../infakt-invoicing.adapter'; +import { InfaktApiError } from '../../../domain/exceptions/infakt-api.error'; +import { FakeInfaktHttpClient } from '../../../testing/fake-infakt-http-client'; +import type { InfaktInvoice, InfaktClient, InfaktListResponse } from '../../../domain/types/infakt.types'; + +function fakeLogger(): jest.Mocked { + return { log: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +function buyer(overrides: Partial<{ nip: string | null }> = {}): BuyerProfile { + return new BuyerProfile( + 'Acme Sp. z o.o.', + overrides.nip === undefined || overrides.nip === null + ? null + : { scheme: 'pl-nip', value: overrides.nip }, + { + line1: 'Testowa 1', + line2: null, + city: 'Warszawa', + postalCode: '00-001', + countryIso2: 'PL', + }, + overrides.nip ? 'company' : 'private', + ); +} + +function invoiceFixture(overrides: Partial = {}): InfaktInvoice { + return { + uuid: 'inv-uuid-1', + number: 'FV/1/2026', + kind: 'vat', + status: 'sent', + gross_price: 123, + net_price: 100, + tax_price: 23, + payment_method: 'transfer', + invoice_date: '2026-07-01', + sale_date: '2026-07-01', + due_date: '2026-07-08', + paid_date: null, + corrected_invoice_number: null, + correction_reason: null, + correction_reason_symbol: null, + ksef_number: null, + ksef_data: null, + client_id: 1, + client_uuid: 'client-uuid-1', + services: [ + { + name: 'Widget', + tax_symbol: '23', + quantity: 1, + unit: 'szt.', + unit_net_price: 100, + net_price: 100, + tax_price: 23, + gross_price: 123, + correction: null, + group: null, + }, + ], + print_url: null, + pdf_url: 'https://infakt.pl/inv-uuid-1.pdf', + ...overrides, + }; +} + +describe('InfaktInvoicingAdapter', () => { + let http: FakeInfaktHttpClient; + let logger: jest.Mocked; + let adapter: InfaktInvoicingAdapter; + + beforeEach(() => { + http = new FakeInfaktHttpClient(); + logger = fakeLogger(); + adapter = new InfaktInvoicingAdapter('conn-1', http, logger); + }); + + describe('getSupportedDocumentTypes', () => { + it('should return the neutral document types Infakt can issue', () => { + expect(adapter.getSupportedDocumentTypes()).toEqual([ + 'invoice', + 'corrected', + 'proforma', + 'prepayment', + ]); + }); + }); + + describe('upsertCustomer', () => { + it('should return an existing client found by NIP (happy path)', async () => { + const existing: InfaktClient = { + id: 1, + uuid: 'client-existing', + name: 'Acme', + nip: '1234567890', + email: null, + city: null, + street: null, + post_code: null, + country: null, + }; + http.seed>('GET', 'clients.json', { + entities: [existing], + metainfo: { total_count: 1, next: null, previous: null }, + }); + + const result = await adapter.upsertCustomer({ + connectionId: 'conn-1', + buyer: buyer({ nip: '1234567890' }), + }); + + expect(result).toEqual({ providerCustomerId: 'client-existing' }); + }); + + it('should create a new client when no NIP match exists (happy path)', async () => { + http.seed>('GET', 'clients.json', { + entities: [], + metainfo: { total_count: 0, next: null, previous: null }, + }); + const created: InfaktClient = { + id: 2, + uuid: 'client-new', + name: 'Acme', + nip: '1234567890', + email: null, + city: 'Warszawa', + street: 'Testowa 1', + post_code: '00-001', + country: 'PL', + }; + http.seed('POST', 'clients.json', created); + + const result = await adapter.upsertCustomer({ + connectionId: 'conn-1', + buyer: buyer({ nip: '1234567890' }), + }); + + expect(result).toEqual({ providerCustomerId: 'client-new' }); + }); + + 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', + nip: null, + email: null, + city: 'Warszawa', + street: 'Testowa 1', + post_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(http.calls.some((c) => c.method === 'GET' && c.path === 'clients.json')).toBe(false); + }); + + it('should propagate InfaktApiError (with failureMode) on a client create rejection (error path)', async () => { + http.seedError('POST', 'clients.json', new InfaktApiError('rejected', 422, { error: 'bad nip' })); + + await expect( + adapter.upsertCustomer({ connectionId: 'conn-1', buyer: buyer({ nip: '0000000000' }) }), + ).rejects.toMatchObject({ failureMode: 'rejected', statusCode: 422 }); + }); + }); + + describe('issueInvoice', () => { + const baseCmd: IssueInvoiceCommand = { + connectionId: 'conn-1', + orderId: 'order-1', + buyer: buyer({ nip: '1234567890' }), + currency: 'PLN', + lines: [{ name: 'Widget', quantity: 1, unitPriceGross: 123, taxRate: '23' }], + idempotencyKey: 'idem-1', + }; + + beforeEach(() => { + // upsertCustomer -> findClientByNip -> none found -> create + http.seed>('GET', 'clients.json', { + entities: [], + metainfo: { total_count: 0, next: null, previous: null }, + }); + http.seed('POST', 'clients.json', { + id: 1, + uuid: 'client-uuid-1', + name: 'Acme', + nip: '1234567890', + email: null, + city: null, + street: null, + post_code: null, + country: null, + }); + }); + + it('should issue an invoice and return an InvoiceRecord (happy path)', async () => { + http.seed('POST', 'invoices.json', invoiceFixture()); + + const record = await adapter.issueInvoice(baseCmd); + + expect(record).toBeInstanceOf(InvoiceRecord); + expect(record.providerType).toBe(INFAKT_PROVIDER_TYPE); + expect(record.providerInvoiceId).toBe('inv-uuid-1'); + expect(record.providerInvoiceNumber).toBe('FV/1/2026'); + expect(record.status).toBe('issued'); + expect(record.idempotencyKey).toBe('idem-1'); + expect(record.pdfUrl).toBe('https://infakt.pl/inv-uuid-1.pdf'); + }); + + it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { + http.seedError( + 'POST', + 'invoices.json', + new InfaktApiError('Infakt rejected the invoice', 422, { error: 'invalid tax data' }), + ); + + await expect(adapter.issueInvoice(baseCmd)).rejects.toMatchObject({ + failureMode: 'rejected', + statusCode: 422, + }); + }); + + it('should propagate a 500 InfaktApiError with failureMode: in-doubt (error path)', async () => { + http.seedError( + 'POST', + 'invoices.json', + new InfaktApiError('Infakt server error', 500, { error: 'internal' }), + ); + + await expect(adapter.issueInvoice(baseCmd)).rejects.toMatchObject({ + failureMode: 'in-doubt', + statusCode: 500, + }); + }); + }); + + describe('getInvoice', () => { + it('should return null when the query is orderId-based (not supported by Infakt)', async () => { + const result = await adapter.getInvoice({ orderId: 'order-1' }); + expect(result).toBeNull(); + }); + + it('should return an InvoiceRecord when found by providerInvoiceId (happy path)', async () => { + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); + + const result = await adapter.getInvoice({ providerInvoiceId: 'inv-uuid-1' }); + + expect(result).toBeInstanceOf(InvoiceRecord); + expect(result?.providerInvoiceId).toBe('inv-uuid-1'); + }); + + it('should return null on a 404 (error path)', async () => { + http.seedError('GET', 'invoices/missing.json', new InfaktApiError('not found', 404, {})); + + const result = await adapter.getInvoice({ providerInvoiceId: 'missing' }); + + expect(result).toBeNull(); + }); + + it('should propagate a non-404 InfaktApiError (error path)', async () => { + http.seedError('GET', 'invoices/inv-uuid-1.json', new InfaktApiError('server error', 500, {})); + + await expect(adapter.getInvoice({ providerInvoiceId: 'inv-uuid-1' })).rejects.toMatchObject({ + statusCode: 500, + }); + }); + }); + + describe('getClearanceStatus', () => { + it('should return not-applicable when the record has no providerInvoiceId', async () => { + const record = new InvoiceRecord( + 'id-1', + 'conn-1', + 'order-1', + INFAKT_PROVIDER_TYPE, + 'invoice', + 'pending', + null, + null, + 'not-applicable', + null, + null, + null, + null, + null, + new Date(), + new Date(), + ); + + const result = await adapter.getClearanceStatus(record); + + expect(result).toEqual({ regulatoryStatus: 'not-applicable' }); + }); + + it.each([ + ['success', 'cleared'], + ['pending', 'submitted'], + ['sent', 'submitted'], + ['error', 'rejected'], + ] as const)('should map ksef_status %s to regulatoryStatus %s (happy path)', async (ksefStatus, expected) => { + const record = new InvoiceRecord( + 'id-1', + 'conn-1', + 'order-1', + INFAKT_PROVIDER_TYPE, + 'invoice', + 'issued', + 'inv-uuid-1', + 'FV/1/2026', + 'not-applicable', + null, + null, + null, + new Date(), + null, + new Date(), + new Date(), + ); + http.seed( + 'GET', + 'invoices/inv-uuid-1.json', + invoiceFixture({ + ksef_data: { + request_uuid: 'req-1', + ksef_number: ksefStatus === 'success' ? 'KSeF-123' : null, + status: ksefStatus, + status_description: null, + timestamps: { request_created_at: null, request_finished_at: null }, + }, + }), + ); + + const result = await adapter.getClearanceStatus(record); + + expect(result.regulatoryStatus).toBe(expected); + }); + + it('should return not-applicable when the invoice has no ksef_data', async () => { + const record = new InvoiceRecord( + 'id-1', + 'conn-1', + 'order-1', + INFAKT_PROVIDER_TYPE, + 'invoice', + 'issued', + 'inv-uuid-1', + 'FV/1/2026', + 'not-applicable', + null, + null, + null, + new Date(), + null, + new Date(), + new Date(), + ); + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture({ ksef_data: null })); + + const result = await adapter.getClearanceStatus(record); + + expect(result.regulatoryStatus).toBe('not-applicable'); + }); + + it('should propagate a transport error (error path)', async () => { + const record = new InvoiceRecord( + 'id-1', + 'conn-1', + 'order-1', + INFAKT_PROVIDER_TYPE, + 'invoice', + 'issued', + 'inv-uuid-1', + 'FV/1/2026', + 'not-applicable', + null, + null, + null, + new Date(), + null, + new Date(), + new Date(), + ); + http.seedError('GET', 'invoices/inv-uuid-1.json', new InfaktApiError('server error', 503, {})); + + await expect(adapter.getClearanceStatus(record)).rejects.toMatchObject({ statusCode: 503 }); + }); + }); + + describe('issueCorrection', () => { + const baseCmd: IssueCorrectionCommand = { + connectionId: 'conn-1', + orderId: 'order-1', + originalProviderInvoiceId: 'inv-uuid-1', + reason: 'Zwrot towaru', + lines: [{ originalLineNumber: 1, newQuantity: 0 }], + idempotencyKey: 'idem-corr-1', + }; + + 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' })); + + const record = await adapter.issueCorrection(baseCmd); + + expect(record).toBeInstanceOf(InvoiceRecord); + expect(record.providerInvoiceId).toBe('corr-uuid-1'); + expect(record.idempotencyKey).toBe('idem-corr-1'); + }); + + it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); + http.seedError( + 'POST', + 'invoices.json', + new InfaktApiError('Infakt rejected the correction', 422, { error: 'bad correction' }), + ); + + await expect(adapter.issueCorrection(baseCmd)).rejects.toMatchObject({ + failureMode: 'rejected', + statusCode: 422, + }); + }); + + it('should propagate a 500 InfaktApiError with failureMode: in-doubt (error path)', async () => { + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); + http.seedError('POST', 'invoices.json', new InfaktApiError('server error', 500, {})); + + await expect(adapter.issueCorrection(baseCmd)).rejects.toMatchObject({ + failureMode: 'in-doubt', + statusCode: 500, + }); + }); + + it('should propagate an error fetching the original invoice (error path)', async () => { + http.seedError('GET', 'invoices/inv-uuid-1.json', new InfaktApiError('not found', 404, {})); + + await expect(adapter.issueCorrection(baseCmd)).rejects.toMatchObject({ statusCode: 404 }); + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-retry-classifier.adapter.spec.ts b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-retry-classifier.adapter.spec.ts new file mode 100644 index 000000000..727357481 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/__tests__/infakt-retry-classifier.adapter.spec.ts @@ -0,0 +1,59 @@ +/** + * Infakt Retry Classifier — Unit Specs + * + * Pins which Infakt HTTP statuses the worker runner must treat as terminal + * (non-retryable) vs transient (retryable), and confirms the classifier + * abstains (returns `false`) for anything that is not an `InfaktApiError` — + * the OR-aggregation contract every classifier in the registry must honour. + * + * @module libs/integrations/infakt/src/infrastructure/adapters/__tests__ + */ +import { InfaktRetryClassifierAdapter } from '../infakt-retry-classifier.adapter'; +import { InfaktApiError } from '../../../domain/exceptions/infakt-api.error'; + +describe('InfaktRetryClassifierAdapter', () => { + const classifier = new InfaktRetryClassifierAdapter(); + + describe('non-retryable (terminal / in-doubt) errors', () => { + it('should be non-retryable for a 400 InfaktApiError (rejected)', () => { + expect(classifier.isNonRetryable(new InfaktApiError('bad request', 400, {}))).toBe(true); + }); + + it('should be non-retryable for a 422 InfaktApiError (rejected)', () => { + expect(classifier.isNonRetryable(new InfaktApiError('unprocessable', 422, {}))).toBe(true); + }); + + it('should be non-retryable for a 404 InfaktApiError (rejected)', () => { + expect(classifier.isNonRetryable(new InfaktApiError('not found', 404, {}))).toBe(true); + }); + + it('should be non-retryable for a 500 InfaktApiError (in-doubt)', () => { + expect(classifier.isNonRetryable(new InfaktApiError('server error', 500, {}))).toBe(true); + }); + + it('should be non-retryable for a 503 InfaktApiError (in-doubt)', () => { + expect(classifier.isNonRetryable(new InfaktApiError('unavailable', 503, {}))).toBe(true); + }); + }); + + describe('retryable (transient) errors', () => { + it('should be retryable for a 429 rate-limit InfaktApiError', () => { + expect(classifier.isNonRetryable(new InfaktApiError('rate limited', 429, {}))).toBe(false); + }); + }); + + describe('abstention for foreign / unrecognized errors', () => { + it('should abstain (false) for a plain Error', () => { + expect(classifier.isNonRetryable(new Error('network failure'))).toBe(false); + }); + + it('should abstain (false) for a non-Error throwable', () => { + expect(classifier.isNonRetryable('not even an error')).toBe(false); + }); + + it('should abstain (false) for null/undefined', () => { + expect(classifier.isNonRetryable(null)).toBe(false); + expect(classifier.isNonRetryable(undefined)).toBe(false); + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-config-shape-validator.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-config-shape-validator.adapter.ts new file mode 100644 index 000000000..1ead36f4d --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-config-shape-validator.adapter.ts @@ -0,0 +1,54 @@ +/** + * Infakt Connection Config Shape Validator + * + * Validates the non-secret config for an Infakt connection: an optional + * `baseUrl` override (sandbox vs production) that, when present, must be a + * non-empty, well-formed URL. Registered against + * `ConnectionConfigShapeValidatorRegistryService` at `infakt.accounting.v1`; + * `ConnectionService` maps the thrown exception to a 400 at the API boundary. + * + * Hand-rolled (no class-validator) — the single optional field doesn't + * justify a DTO graph, and the plugin stays dependency-light. Error messages + * use neutral terminology only. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + * @see {@link ConnectionConfigShapeValidatorPort} + */ +import { + type ConnectionConfigShapeValidatorPort, + type FlatValidationIssue, + InvalidConnectionConfigException, +} from '@openlinker/core/integrations'; + +export class InfaktConnectionConfigShapeValidatorAdapter + implements ConnectionConfigShapeValidatorPort +{ + constructor(private readonly pluginName: string = 'Infakt') {} + + validate(config: Record): Promise { + const issues: FlatValidationIssue[] = []; + const baseUrl = config.baseUrl; + + if (baseUrl !== undefined && baseUrl !== null) { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + issues.push({ path: 'baseUrl', message: 'must be a non-empty string' }); + } else if (!this.isValidUrl(baseUrl)) { + issues.push({ path: 'baseUrl', message: 'must be a valid URL' }); + } + } + + if (issues.length > 0) { + return Promise.reject(new InvalidConnectionConfigException(this.pluginName, issues)); + } + return Promise.resolve(); + } + + private isValidUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } + } +} diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter.ts new file mode 100644 index 000000000..4df4204b0 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-connection-credentials-shape-validator.adapter.ts @@ -0,0 +1,42 @@ +/** + * Infakt Connection Credentials Shape Validator + * + * Validates the credentials payload for an Infakt connection: a required, + * non-empty `apiKey` string. Registered against + * `ConnectionCredentialsShapeValidatorRegistryService` at + * `infakt.accounting.v1`; `ConnectionService` maps the thrown exception to a + * 400 at the API boundary. + * + * Validating shape BEFORE persistence keeps malformed credentials out of the + * DB so a connection can never reach the adapter factory with an + * unresolvable credential. + * + * Hand-rolled (no class-validator) to stay dependency-light. Error detail + * never echoes the `apiKey` value. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + * @see {@link ConnectionCredentialsShapeValidatorPort} + */ +import { + type ConnectionCredentialsShapeValidatorPort, + InvalidCredentialsShapeException, +} from '@openlinker/core/integrations'; + +export class InfaktConnectionCredentialsShapeValidatorAdapter + implements ConnectionCredentialsShapeValidatorPort +{ + constructor(private readonly pluginName: string = 'Infakt') {} + + validate(credentials: Record): Promise { + const apiKey = credentials.apiKey; + if (typeof apiKey !== 'string' || apiKey.trim().length === 0) { + return Promise.reject( + new InvalidCredentialsShapeException( + this.pluginName, + 'must include a non-empty `apiKey` string', + ), + ); + } + return Promise.resolve(); + } +} diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts new file mode 100644 index 000000000..1229bfe2f --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -0,0 +1,363 @@ +/** + * Infakt Invoicing Adapter + * + * Implements `InvoicingPort`, `RegulatoryStatusReader`, and `CorrectionIssuer` + * 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). + * + * @module libs/integrations/infakt/src/infrastructure/adapters + */ +import { randomUUID } from 'crypto'; +import type { LoggerPort } from '@openlinker/shared/logging'; +import type { + CorrectionIssuer, + DocumentType, + GetInvoiceQuery, + IssueCorrectionCommand, + IssueInvoiceCommand, + InvoicingPort, + RegulatoryClearanceResult, + RegulatoryStatus, + RegulatoryStatusReader, + UpsertCustomerCommand, + UpsertCustomerResult, +} from '@openlinker/core/invoicing'; +import { InvoiceRecord } from '@openlinker/core/invoicing'; +import type { IInfaktHttpClient } from '../http/infakt-http-client.interface'; +import { InfaktApiError } from '../../domain/exceptions/infakt-api.error'; +import type { + InfaktClient, + InfaktInvoice, + InfaktKsefStatus, + InfaktListResponse, + InfaktSendToKsefResponse, +} from '../../domain/types/infakt.types'; + +export const INFAKT_PROVIDER_TYPE = 'infakt'; + +const SUPPORTED_DOCUMENT_TYPES: readonly DocumentType[] = [ + 'invoice', + 'corrected', + 'proforma', + 'prepayment', +]; + +/** Maps Infakt ksef_data.status → neutral RegulatoryStatus. */ +function toRegulatoryStatus(ksefStatus: InfaktKsefStatus | null | undefined): RegulatoryStatus { + if (!ksefStatus) return 'not-applicable'; + switch (ksefStatus) { + case 'pending': + case 'sent': + return 'submitted'; + case 'success': + return 'cleared'; + case 'error': + return 'rejected'; + } +} + +/** Maps neutral taxRate string to Infakt tax_symbol. */ +function toInfaktTaxSymbol(taxRate: string): string { + // Common neutral→Infakt mapping; adapter owns this PL logic + switch (taxRate) { + case '23': + case '0.23': + return '23'; + case '8': + case '0.08': + return '8'; + case '5': + case '0.05': + return '5'; + case '0': + case '0.00': + case 'zw': + case 'exempt': + return 'zw'; + case 'np': + case 'oo': + return 'np'; + default: + return taxRate; + } +} + +export class InfaktInvoicingAdapter + implements InvoicingPort, RegulatoryStatusReader, CorrectionIssuer +{ + constructor( + private readonly connectionId: string, + private readonly http: IInfaktHttpClient, + private readonly logger: LoggerPort, + ) {} + + getSupportedDocumentTypes(): DocumentType[] { + return [...SUPPORTED_DOCUMENT_TYPES]; + } + + async upsertCustomer(cmd: UpsertCustomerCommand): Promise { + const { buyer } = cmd; + // Infakt uses NIP (pl-nip scheme) for B2B client dedup + const nip = buyer.taxId?.scheme === 'pl-nip' ? buyer.taxId.value : null; + + // Search for existing client by NIP first + 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 }; + } + } + + // Create new client + const payload = { + client: { + name: buyer.name, + nip: nip ?? undefined, + city: buyer.address.city, + street: buyer.address.line1, + post_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 }; + } + + async issueInvoice(cmd: IssueInvoiceCommand): Promise { + const { lines, currency, documentType, idempotencyKey, orderId } = cmd; + const clientUuid = await this.resolveClientUuid(cmd); + + const kind = documentType === 'proforma' ? 'proforma' : 'vat'; + const services = lines.map((l) => ({ + name: l.name, + tax_symbol: toInfaktTaxSymbol(l.taxRate), + quantity: l.quantity, + unit: 'szt.', + unit_net_price: `${l.unitPriceGross / (1 + this.taxRateNumeric(l.taxRate))} ${currency ?? 'PLN'}`, + })); + + const payload = { + invoice: { + kind, + payment_method: 'transfer', + client_uuid: clientUuid, + services, + ...(idempotencyKey ? { external_id: idempotencyKey } : {}), + }, + }; + + // InfaktApiError carries the neutral `failureMode` discriminator core's + // InvoiceService reads structurally (#1200) — propagate as-is rather + // than wrapping into a plain Error, which would erase that signal. + const invoice = await this.http.post('invoices.json', payload); + + this.logger.log(`Infakt invoice created: ${invoice.uuid} (${invoice.number ?? 'draft'})`); + + const ksefStatus = invoice.ksef_data?.status ?? null; + const now = new Date(); + return new InvoiceRecord( + randomUUID(), + this.connectionId, + orderId, + INFAKT_PROVIDER_TYPE, + documentType ?? 'invoice', + 'issued', + invoice.uuid, + invoice.number ?? null, + toRegulatoryStatus(ksefStatus), + invoice.ksef_data?.ksef_number ?? null, + idempotencyKey ?? null, + invoice.pdf_url ?? null, + now, + null, + now, + now, + ); + } + + async getInvoice(query: GetInvoiceQuery): Promise { + const providerInvoiceId = + 'providerInvoiceId' in query ? query.providerInvoiceId : null; + if (!providerInvoiceId) { + // orderId-based lookup not supported by Infakt; must go via OL's own store + return null; + } + + try { + const invoice = await this.http.get( + `invoices/${providerInvoiceId}.json`, + { invoice_type: invoice_kind_to_type(invoice_uuid_kind(providerInvoiceId)) }, + ); + const now = new Date(); + return new InvoiceRecord( + randomUUID(), + this.connectionId, + '', + INFAKT_PROVIDER_TYPE, + invoice.kind === 'corrective' ? 'corrected' : 'invoice', + 'issued', + invoice.uuid, + invoice.number ?? null, + toRegulatoryStatus(invoice.ksef_data?.status ?? null), + invoice.ksef_data?.ksef_number ?? null, + null, + invoice.pdf_url ?? null, + invoice.invoice_date ? new Date(invoice.invoice_date) : now, + null, + now, + now, + ); + } catch (err) { + if (err instanceof InfaktApiError && err.statusCode === 404) return null; + throw err; + } + } + + async getClearanceStatus(record: InvoiceRecord): Promise { + if (!record.providerInvoiceId) { + return { regulatoryStatus: 'not-applicable' }; + } + + const invoice = await this.http.get( + `invoices/${record.providerInvoiceId}.json`, + { invoice_type: 'vat' }, + ); + + const ksefData = invoice.ksef_data; + return { + regulatoryStatus: toRegulatoryStatus(ksefData?.status ?? null), + clearanceReference: ksefData?.ksef_number ?? null, + }; + } + + async issueCorrection(cmd: IssueCorrectionCommand): Promise { + const { originalProviderInvoiceId, reason, lines, documentType, idempotencyKey, orderId } = + cmd; + + // Fetch original to build the before/after service arrays + const original = await this.http.get( + `invoices/${originalProviderInvoiceId}.json`, + { invoice_type: 'vat' }, + ); + + // 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; + const corrPrice = corrLine?.newUnitPriceGross + ? `${corrLine.newUnitPriceGross} PLN` + : `${svc.unit_net_price} PLN`; + return [ + // Original "before" row + { + name: svc.name, + tax_symbol: svc.tax_symbol, + quantity: svc.quantity, + unit: svc.unit ?? 'szt.', + unit_net_price: `${svc.unit_net_price} PLN`, + group: idx + 1, + correction: false, + }, + // Corrected "after" row + { + name: svc.name, + tax_symbol: svc.tax_symbol, + quantity: corrQty, + unit: svc.unit ?? 'szt.', + unit_net_price: corrPrice, + group: idx + 1, + correction: true, + }, + ]; + }); + + const payload = { + invoice: { + kind: 'corrective', + payment_method: 'cash', + corrected_invoice_number: original.number, + corrected_invoice_date: original.invoice_date ?? new Date().toISOString().slice(0, 10), + correction_reason_symbol: 'other', + correction_reason: reason ?? 'Korekta', + services: correctionServices, + ...(idempotencyKey ? { external_id: idempotencyKey } : {}), + }, + }; + + // InfaktApiError carries `failureMode`; propagate as-is (see issueInvoice). + const invoice = await this.http.post('invoices.json', payload); + + this.logger.log(`Infakt correction created: ${invoice.uuid} (${invoice.number ?? 'draft'})`); + const now = new Date(); + return new InvoiceRecord( + randomUUID(), + this.connectionId, + orderId, + INFAKT_PROVIDER_TYPE, + documentType ?? 'corrected', + 'issued', + invoice.uuid, + invoice.number ?? null, + 'not-applicable', + null, + idempotencyKey ?? null, + invoice.pdf_url ?? null, + now, + null, + now, + now, + ); + } + + // --- Infakt-specific: trigger KSeF submission --- + + async sendToKsef(invoiceUuid: string): Promise { + return this.http.post( + `invoices/${invoiceUuid}/send_to_ksef.json`, + {}, + ); + } + + // --- helpers --- + + private async resolveClientUuid(cmd: IssueInvoiceCommand): Promise { + const result = await this.upsertCustomer({ connectionId: cmd.connectionId, buyer: cmd.buyer }); + return result.providerCustomerId; + } + + private async findClientByNip(nip: string): Promise { + try { + const list = await this.http.get>('clients.json', { + nip, + }); + return list.entities[0] ?? null; + } catch { + return null; + } + } + + private taxRateNumeric(taxRate: string): number { + const n = parseFloat(taxRate); + if (!isNaN(n) && n > 1) return n / 100; + if (!isNaN(n)) return n; + return 0; + } +} + +// Infakt requires invoice_type query param on GET; for POC we default to 'vat' +function invoice_uuid_kind(_uuid: string): string { + return 'vat'; +} +function invoice_kind_to_type(kind: string): string { + return kind === 'corrective' ? 'corrective' : 'vat'; +} diff --git a/libs/integrations/infakt/src/infrastructure/adapters/infakt-retry-classifier.adapter.ts b/libs/integrations/infakt/src/infrastructure/adapters/infakt-retry-classifier.adapter.ts new file mode 100644 index 000000000..0329d694d --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-retry-classifier.adapter.ts @@ -0,0 +1,65 @@ +/** + * Infakt Retry Classifier Adapter + * + * Implements `RetryClassifierPort` for Infakt — answers the worker runner's + * "is this error non-retryable?" question for Infakt's own exception + * hierarchy (`InfaktApiError`). Without this the registry defaults every + * unknown error to retryable, which is fiscally unsafe for a 5xx / network + * failure that may have reached Infakt's servers before the response was + * lost — Infakt could have already created the invoice (and possibly + * submitted it to KSeF), so a blind retry risks a double-issued fiscal + * document. + * + * Classification (fiscal-safety framing, mirrors `SubiektRetryClassifierAdapter`): + * - `4xx` except `429` (`InfaktApiError.isClientError()` minus rate-limit) — + * REJECTED. Terminal and deterministic: the request never became a + * document, so retrying the same job burns capacity for nothing. Safe for + * a human/business process to issue a corrected invoice afterwards, but + * that is a distinct concern from the runner's auto-retry decision. + * - `429` — TRANSIENT. Rate-limited; the runner should back off and retry. + * - `5xx` (`InfaktApiError.isServerError()`) — IN-DOUBT. The document may + * already exist on Infakt's side (and may already be mid-flight to + * KSeF); auto-retrying blind risks a double-issued fiscal document, so + * this is classified non-retryable until Infakt's API guarantees + * idempotency-key dedup on the write path. + * + * The registry OR-aggregates every registered classifier's answer with no + * platform scoping (`RetryClassifierRegistryService.isNonRetryable`), so — + * mirroring `SubiektRetryClassifierAdapter` / `KsefRetryClassifierAdapter` — + * this classifier recognises ONLY `InfaktApiError` (its own exception type) + * and abstains (`false`) for everything else, including a bare network / + * transport failure that never became an `InfaktApiError` at all. A + * catch-all `true` here would turn every sibling plugin's transient error + * (and Infakt's own network blips) non-retryable. + * + * Self-registered by the Infakt plugin's `register(host)` against + * `RetryClassifierRegistryService`. + * + * @module libs/integrations/infakt/src/infrastructure/adapters + * @implements {RetryClassifierPort} + */ +import type { RetryClassifierPort } from '@openlinker/core/sync'; +import { InfaktApiError } from '../../domain/exceptions/infakt-api.error'; + +export class InfaktRetryClassifierAdapter implements RetryClassifierPort { + isNonRetryable(cause: unknown): boolean { + if (!(cause instanceof InfaktApiError)) { + // Not ours — abstain. Includes bare network/transport failures, which + // never surface as InfaktApiError (only non-2xx HTTP responses do). + return false; + } + + if (cause.statusCode === 429) { + // Transient rate-limit — retryable. + return false; + } + if (cause.isClientError()) { + // Deterministic 4xx rejection — terminal, non-retryable. + return true; + } + // 5xx — IN-DOUBT: the document may already exist on Infakt's side. + // Fiscal-safety pivot: non-retryable until Infakt guarantees + // idempotency-key dedup on the write path. + return true; + } +} diff --git a/libs/integrations/infakt/src/infrastructure/http/__tests__/infakt-http-client.spec.ts b/libs/integrations/infakt/src/infrastructure/http/__tests__/infakt-http-client.spec.ts new file mode 100644 index 000000000..0ecfb0361 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/http/__tests__/infakt-http-client.spec.ts @@ -0,0 +1,155 @@ +/** + * Infakt HTTP Client — unit tests + * + * Stubs `global.fetch` to verify the `X-inFakt-ApiKey` header injection, JSON + * serialize/deserialize on GET/POST, query-string building, and + * `InfaktApiError` on non-2xx responses (including a non-JSON response body). + * + * @module libs/integrations/infakt/src/infrastructure/http/__tests__ + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import { InfaktApiError } from '../../../domain/exceptions/infakt-api.error'; +import { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from '../infakt-http-client'; + +function fakeResponse(status: number, body: string): Response { + return { + ok: status >= 200 && status < 300, + status, + text: (): Promise => Promise.resolve(body), + } as unknown as Response; +} + +function fakeLogger(): jest.Mocked { + return { + log: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +} + +const originalFetch = global.fetch; + +describe('InfaktHttpClient', () => { + let fetchMock: jest.Mock; + let logger: jest.Mocked; + let client: InfaktHttpClient; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + logger = fakeLogger(); + client = new InfaktHttpClient({ apiKey: 'test-api-key' }, logger); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('baseUrl resolution', () => { + it('should default to INFAKT_DEFAULT_BASE_URL when no baseUrl is configured', async () => { + fetchMock.mockResolvedValue(fakeResponse(200, '{}')); + await client.get('invoices.json'); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe(`${INFAKT_DEFAULT_BASE_URL}/invoices.json`); + }); + + it('should strip a trailing slash from a configured baseUrl', async () => { + const sandboxClient = new InfaktHttpClient( + { apiKey: 'k', baseUrl: 'https://api.sandbox.infakt.pl/api/v3/' }, + logger, + ); + fetchMock.mockResolvedValue(fakeResponse(200, '{}')); + await sandboxClient.get('invoices.json'); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://api.sandbox.infakt.pl/api/v3/invoices.json'); + }); + }); + + describe('GET', () => { + it('should attach the X-inFakt-ApiKey header', async () => { + fetchMock.mockResolvedValue(fakeResponse(200, '{"uuid":"abc"}')); + await client.get('invoices/abc.json'); + const [, init] = fetchMock.mock.calls[0] as [string, { headers: Record }]; + expect(init.headers['X-inFakt-ApiKey']).toBe('test-api-key'); + }); + + it('should append query params as a query string', async () => { + fetchMock.mockResolvedValue(fakeResponse(200, '{}')); + await client.get('clients.json', { nip: '1234567890' }); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe(`${INFAKT_DEFAULT_BASE_URL}/clients.json?nip=1234567890`); + }); + + it('should omit the query string when query is empty', async () => { + fetchMock.mockResolvedValue(fakeResponse(200, '{}')); + await client.get('clients.json', {}); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe(`${INFAKT_DEFAULT_BASE_URL}/clients.json`); + }); + + it('should deserialize the JSON response body', async () => { + fetchMock.mockResolvedValue(fakeResponse(200, '{"uuid":"abc-123"}')); + const result = await client.get<{ uuid: string }>('invoices/abc-123.json'); + expect(result).toEqual({ uuid: 'abc-123' }); + }); + }); + + describe('POST', () => { + it('should attach the X-inFakt-ApiKey header and Content-Type', async () => { + fetchMock.mockResolvedValue(fakeResponse(201, '{"uuid":"new-1"}')); + await client.post('invoices.json', { invoice: { kind: 'vat' } }); + const [, init] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + expect(init.method).toBe('POST'); + expect(init.headers['X-inFakt-ApiKey']).toBe('test-api-key'); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('should serialize the request body as JSON', async () => { + fetchMock.mockResolvedValue(fakeResponse(201, '{}')); + const payload = { invoice: { kind: 'vat', client_uuid: 'c-1' } }; + await client.post('invoices.json', payload); + const [, init] = fetchMock.mock.calls[0] as [string, { body: string }]; + expect(JSON.parse(init.body)).toEqual(payload); + }); + + it('should deserialize the JSON response body', async () => { + fetchMock.mockResolvedValue(fakeResponse(201, '{"uuid":"new-1","number":null}')); + const result = await client.post<{ uuid: string }>('invoices.json', {}); + expect(result).toEqual({ uuid: 'new-1', number: null }); + }); + }); + + describe('error handling', () => { + it('should throw InfaktApiError on a non-2xx JSON response', async () => { + fetchMock.mockResolvedValue(fakeResponse(422, '{"error":"invalid nip"}')); + await expect(client.post('invoices.json', {})).rejects.toBeInstanceOf(InfaktApiError); + await expect(client.post('invoices.json', {})).rejects.toMatchObject({ + statusCode: 422, + responseBody: { error: 'invalid nip' }, + }); + }); + + it('should throw InfaktApiError with statusCode on a 500 response', async () => { + fetchMock.mockResolvedValue(fakeResponse(500, '{"error":"internal"}')); + await expect(client.get('invoices/x.json')).rejects.toMatchObject({ statusCode: 500 }); + }); + + it('should throw InfaktApiError carrying the raw text when the body is not JSON', async () => { + fetchMock.mockResolvedValue(fakeResponse(502, 'Bad Gateway')); + await expect(client.get('invoices/x.json')).rejects.toMatchObject({ + statusCode: 502, + responseBody: 'Bad Gateway', + }); + }); + + it('should log a warning on a non-2xx response', async () => { + fetchMock.mockResolvedValue(fakeResponse(404, '{"error":"not found"}')); + await expect(client.get('invoices/missing.json')).rejects.toBeInstanceOf(InfaktApiError); + expect(logger.warn).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.interface.ts b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.interface.ts new file mode 100644 index 000000000..24eba0e19 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.interface.ts @@ -0,0 +1,18 @@ +/** + * Infakt HTTP Client Port + * + * Transport contract the Infakt capability adapter codes against. Keeping it + * an interface — not the concrete `InfaktHttpClient` — lets adapter unit + * specs substitute an in-memory fake without a real `fetch`, per + * engineering-standards § "Interface and Implementation Separation". + * + * Package-private: consumed only by the in-package factory + adapter via + * relative import; intentionally NOT re-exported from the package barrel + * (mirrors the KSeF `IKsefHttpClient` precedent). + * + * @module libs/integrations/infakt/src/infrastructure/http + */ +export interface IInfaktHttpClient { + get(path: string, query?: Record): Promise; + post(path: string, body: unknown): Promise; +} diff --git a/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts new file mode 100644 index 000000000..956acbae5 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts @@ -0,0 +1,80 @@ +/** + * Infakt HTTP Client + * + * Thin fetch wrapper for Infakt REST API v3. Attaches the `X-inFakt-ApiKey` + * header on every request. Throws `InfaktApiError` on non-2xx responses so the + * adapter can distinguish transport errors from provider rejections. + * + * @module libs/integrations/infakt/src/infrastructure/http + * @implements {IInfaktHttpClient} + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import { InfaktApiError } from '../../domain/exceptions/infakt-api.error'; +import type { IInfaktHttpClient } from './infakt-http-client.interface'; + +export interface InfaktHttpClientConfig { + apiKey: string; + baseUrl?: string; +} + +export const INFAKT_DEFAULT_BASE_URL = 'https://api.infakt.pl/api/v3'; + +export class InfaktHttpClient implements IInfaktHttpClient { + private readonly baseUrl: string; + + constructor( + private readonly config: InfaktHttpClientConfig, + private readonly logger: LoggerPort, + ) { + this.baseUrl = config.baseUrl?.replace(/\/$/, '') ?? INFAKT_DEFAULT_BASE_URL; + } + + async get(path: string, query?: Record): Promise { + const url = this.buildUrl(path, query); + const res = await fetch(url, { headers: this.headers() }); + return this.parse(res, 'GET', path); + } + + async post(path: string, body: unknown): Promise { + const url = this.buildUrl(path); + const res = await fetch(url, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return this.parse(res, 'POST', path); + } + + private buildUrl(path: string, query?: Record): string { + const base = `${this.baseUrl}/${path.replace(/^\//, '')}`; + if (!query || Object.keys(query).length === 0) return base; + return `${base}?${new URLSearchParams(query).toString()}`; + } + + private headers(): Record { + return { 'X-inFakt-ApiKey': this.config.apiKey }; + } + + private async parse(res: Response, method: string, path: string): Promise { + const text = await res.text(); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new InfaktApiError( + `Infakt API ${method} ${path} returned non-JSON (${res.status})`, + res.status, + text, + ); + } + if (!res.ok) { + this.logger.warn(`Infakt API ${method} ${path} → ${res.status}`, { body: text }); + throw new InfaktApiError( + `Infakt API ${method} ${path} failed with status ${res.status}`, + res.status, + json, + ); + } + return json as T; + } +} 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 new file mode 100644 index 000000000..9c4fb0a5c --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/webhooks/__tests__/infakt-webhook-translator.spec.ts @@ -0,0 +1,130 @@ +/** + * Infakt Webhook Translator — unit tests + * + * Verifies HMAC signature verification (valid/invalid/missing), the + * verification-handshake echo, payload parsing, the `ksef_status` -> + * canonical-domain mapping, and that an unknown event maps to `null`. + * + * @module libs/integrations/infakt/src/infrastructure/webhooks/__tests__ + */ +import { createHmac } from 'crypto'; +import type { LoggerPort } from '@openlinker/shared/logging'; +import { InfaktWebhookTranslator } from '../infakt-webhook-translator'; + +const SECRET = 'test-webhook-secret'; + +function fakeLogger(): jest.Mocked { + return { log: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +function sign(body: Buffer, secret: string): string { + return createHmac('sha256', secret).update(body).digest('hex'); +} + +describe('InfaktWebhookTranslator', () => { + let logger: jest.Mocked; + let translator: InfaktWebhookTranslator; + + beforeEach(() => { + logger = fakeLogger(); + translator = new InfaktWebhookTranslator({ secret: SECRET }, logger); + }); + + describe('verifySignature', () => { + it('should return true for a valid signature', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(translator.verifySignature(body, sign(body, SECRET))).toBe(true); + }); + + it('should return false for an invalid signature', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(translator.verifySignature(body, 'deadbeef')).toBe(false); + }); + + it('should return false when the signature header is missing', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(translator.verifySignature(body, undefined)).toBe(false); + }); + + it('should return false for a signature signed with the wrong secret', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(translator.verifySignature(body, sign(body, 'wrong-secret'))).toBe(false); + }); + }); + + describe('getVerificationEcho', () => { + it('should echo the verification_code when present', () => { + const body = Buffer.from(JSON.stringify({ verification_code: 'abc123' })); + expect(translator.getVerificationEcho(body)).toEqual({ verification_code: 'abc123' }); + }); + + it('should return null when the payload has no verification_code', () => { + const body = Buffer.from(JSON.stringify({ event: { name: 'x' } })); + expect(translator.getVerificationEcho(body)).toBeNull(); + }); + + it('should return null when the payload is not valid JSON', () => { + const body = Buffer.from('not json'); + expect(translator.getVerificationEcho(body)).toBeNull(); + }); + }); + + describe('parse', () => { + it('should parse a well-formed event payload', () => { + const body = Buffer.from( + JSON.stringify({ + event: { uuid: 'e-1', name: 'send_to_ksef_success', retry_counter: 0, created_at: 'now' }, + resource: { status: 'success', invoice_uuid: 'inv-1' }, + }), + ); + const result = translator.parse(body); + expect(result?.event.name).toBe('send_to_ksef_success'); + expect(result?.resource).toEqual({ status: 'success', invoice_uuid: 'inv-1' }); + }); + + it('should return null and log a warning when the payload is missing the event field', () => { + const body = Buffer.from(JSON.stringify({ resource: {} })); + expect(translator.parse(body)).toBeNull(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should return null and log a warning when the payload is not valid JSON', () => { + const body = Buffer.from('not json'); + expect(translator.parse(body)).toBeNull(); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe('toOlDomain', () => { + it('should map send_to_ksef_success to invoicing', () => { + expect(translator.toOlDomain('send_to_ksef_success')).toBe('invoicing'); + }); + + it('should map send_to_ksef_error to invoicing', () => { + expect(translator.toOlDomain('send_to_ksef_error')).toBe('invoicing'); + }); + + it('should map an unknown event to null', () => { + expect(translator.toOlDomain('some_future_event')).toBeNull(); + }); + + it('should map draft_invoice_created to null (not a KSeF status event)', () => { + expect(translator.toOlDomain('draft_invoice_created')).toBeNull(); + }); + }); + + describe('toKsefResource', () => { + it('should narrow a valid KSeF resource shape', () => { + const resource = { status: 'success', invoice_uuid: 'inv-1', ksef_number: 'KSeF-1' }; + expect(translator.toKsefResource(resource)).toEqual(resource); + }); + + it('should return null when the resource is missing invoice_uuid', () => { + expect(translator.toKsefResource({ status: 'success' })).toBeNull(); + }); + + it('should return null when the resource is missing status', () => { + expect(translator.toKsefResource({ invoice_uuid: 'inv-1' })).toBeNull(); + }); + }); +}); diff --git a/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts new file mode 100644 index 000000000..3386f27f4 --- /dev/null +++ b/libs/integrations/infakt/src/infrastructure/webhooks/infakt-webhook-translator.ts @@ -0,0 +1,184 @@ +/** + * Infakt Webhook Translator + * + * Translates inbound Infakt webhook payloads to canonical OL events. + * + * Infakt webhook facts (confirmed live 2026-06-30 on sandbox): + * - Subscriptions configured via Infakt web UI only — no REST API. + * - Verification flow: Infakt POSTs {"verification_code":""} → endpoint + * must echo {"verification_code":""} back → webhook becomes active. + * - Every delivery carries X-Infakt-Signature = HMAC-SHA256(rawBody, secret). + * Secret is auto-generated per subscription, visible in webhook details UI. + * Return 401 on bad signature. + * - User-Agent: Infakt-Webhooks/2 + * + * Confirmed live event names (sandbox, 2026-06-30): + * draft_invoice_created — "Faktura utworzona (szkic)" + * send_to_ksef_success — "Faktura wysłana do KSEF" ← key for OL clearance update + * send_to_ksef_error — "Błąd wysyłki faktury do KSEF" (inferred) + * + * Payload shape: + * { event: { uuid, name, retry_counter, created_at }, resource: } + * + * Resource shape for send_to_ksef_success (confirmed): + * { status, ksef_number, invoice_uuid, invoice_kind, request_uuid, + * status_description, timestamps: { request_created_at, request_finished_at } } + * + * Resource shape for draft_invoice_created (confirmed): + * full invoice object (same as GET /invoices/{uuid}.json) + * + * @module libs/integrations/infakt/src/infrastructure/webhooks + */ +import { createHmac, timingSafeEqual } from 'crypto'; +import type { LoggerPort } from '@openlinker/shared/logging'; + +export interface InfaktWebhookEvent { + event: { + uuid: string; + name: string; + retry_counter: number; + created_at: string; + }; + resource: Record; +} + +/** Resource shape for send_to_ksef_success / send_to_ksef_error (confirmed live). */ +export interface InfaktKsefWebhookResource { + status: 'success' | 'error'; + ksef_number: string | null; + invoice_uuid: string; + invoice_kind: string; + request_uuid: string; + status_description: string | null; + timestamps: { + request_created_at: string; + request_finished_at: string; + }; +} + +/** + * Confirmed live event names (documented for readers; NOT a closed union — + * Infakt may add new events, so the runtime type is a bare `string`. Listing + * literals alongside `string` in the same union is redundant per + * `@typescript-eslint/no-redundant-type-constituents` (the literals widen to + * `string` at the type level), so the well-known values live here as + * documentation only, mirroring the `PromptTemplateChannel = string` / + * `CoreCapability` open-world precedent: + * - `draft_invoice_created` + * - `send_to_ksef_success` + * - `send_to_ksef_error` + * - `invoice_created_via_async_api` + * - `invoice_creation_error_via_async_api` + * - `invoice_marked_as_paid_via_async_api` + * - `invoice_marking_as_paid_error_via_async_api` + * - `invoice_deleted` + * - `invoice_marked_as_paid` + * Additional events beyond this list are available in the Infakt UI but not + * yet confirmed in this POC. + */ +export type InfaktWebhookEventName = string; + +export interface InfaktWebhookTranslatorConfig { + /** HMAC-SHA256 secret from Infakt webhook settings (UI-generated, per-subscription). */ + secret: string; +} + +export class InfaktWebhookTranslator { + constructor( + private readonly config: InfaktWebhookTranslatorConfig, + private readonly logger: LoggerPort, + ) {} + + /** + * 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. + */ + getVerificationEcho(rawBody: Buffer): { verification_code: string } | null { + try { + const parsed = JSON.parse(rawBody.toString('utf-8')) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + 'verification_code' in parsed && + typeof (parsed as Record)['verification_code'] === 'string' + ) { + return { verification_code: (parsed as { verification_code: string }).verification_code }; + } + } catch { + // not JSON + } + return null; + } + + /** + * Verifies the X-Infakt-Signature header against the raw request body. + * Returns false if the signature is missing, malformed, or invalid. + * Callers should respond HTTP 401 on false. + */ + verifySignature(rawBody: Buffer, signatureHeader: string | undefined): boolean { + if (!signatureHeader) return false; + try { + const expected = createHmac('sha256', this.config.secret) + .update(rawBody) + .digest('hex'); + const expectedBuf = Buffer.from(expected); + const actualBuf = Buffer.from(signatureHeader); + if (expectedBuf.length !== actualBuf.length) return false; + return timingSafeEqual(expectedBuf, actualBuf); + } catch { + return false; + } + } + + /** + * Parses and validates the webhook payload JSON. + * Returns null if the payload is malformed or missing required fields. + */ + parse(rawBody: Buffer): InfaktWebhookEvent | null { + try { + const payload = JSON.parse(rawBody.toString('utf-8')) as unknown; + if ( + typeof payload !== 'object' || + payload === null || + !('event' in payload) || + typeof (payload as Record)['event'] !== 'object' + ) { + this.logger.warn('Infakt webhook: malformed payload (missing event field)'); + return null; + } + return payload as InfaktWebhookEvent; + } catch { + this.logger.warn('Infakt webhook: payload is not valid JSON'); + return null; + } + } + + /** + * Maps an Infakt event name to the OL canonical domain. + * Returns null for events OL does not handle (ACK with 200 and ignore). + */ + toOlDomain(eventName: InfaktWebhookEventName): 'invoicing' | null { + switch (eventName) { + case 'send_to_ksef_success': + case 'send_to_ksef_error': + return 'invoicing'; + default: + return null; + } + } + + /** + * Narrows resource to InfaktKsefWebhookResource for KSeF events. + * Returns null if the resource doesn't match the expected shape. + */ + toKsefResource(resource: Record): InfaktKsefWebhookResource | null { + if ( + typeof resource['invoice_uuid'] === 'string' && + typeof resource['status'] === 'string' + ) { + return resource as unknown as InfaktKsefWebhookResource; + } + return null; + } +} diff --git a/libs/integrations/infakt/src/scripts/poc-sandbox-test.ts b/libs/integrations/infakt/src/scripts/poc-sandbox-test.ts new file mode 100644 index 000000000..6fbb50b35 --- /dev/null +++ b/libs/integrations/infakt/src/scripts/poc-sandbox-test.ts @@ -0,0 +1,254 @@ +/** + * Infakt Sandbox POC Test Script + * + * Runs a full E2E verification against the Infakt sandbox API: + * 1. upsertCustomer — find or create a test buyer by NIP + * 2. issueInvoice — create a VAT invoice + * 3. getInvoice — read back the created invoice by UUID + * 4. getClearanceStatus — read ksef_data (before KSeF submit) + * 5. sendToKsef — trigger KSeF submission + * 6. pollUntilCleared — poll getClearanceStatus until success/error (max 3 min) + * + * Usage: + * INFAKT_SANDBOX_API_KEY= npx ts-node src/scripts/poc-sandbox-test.ts + * + * Optional env: + * INFAKT_BASE_URL — defaults to https://api.sandbox-infakt.pl/api/v3 + * POC_POLL_MAX_MS — max poll time in ms (default 180000 = 3 min) + * POC_POLL_INTERVAL_MS — poll interval (default 10000 = 10 s) + * + * Verified live 2026-06-30: full draft→KSeF clearance in ~90 s on sandbox. + */ + +/* eslint-disable no-console -- POC script intentionally uses console for output */ + +import { InfaktHttpClient } from '../infrastructure/http/infakt-http-client'; +import { InfaktInvoicingAdapter } from '../infrastructure/adapters/infakt-invoicing.adapter'; +import type { InfaktSendToKsefResponse } from '../domain/types/infakt.types'; +import { BuyerProfile } from '@openlinker/core/invoicing'; + +// ---- config ---------------------------------------------------------------- + +const API_KEY = process.env['INFAKT_SANDBOX_API_KEY'] ?? ''; +const BASE_URL = + process.env['INFAKT_BASE_URL'] ?? 'https://api.sandbox-infakt.pl/api/v3'; +const POLL_MAX_MS = parseInt(process.env['POC_POLL_MAX_MS'] ?? '180000', 10); +const POLL_INTERVAL_MS = parseInt(process.env['POC_POLL_INTERVAL_MS'] ?? '10000', 10); + +const CONNECTION_ID = 'poc-sandbox-connection'; + +if (!API_KEY) { + console.error('ERROR: set INFAKT_SANDBOX_API_KEY env var'); + process.exit(1); +} + +// ---- logger (console) ------------------------------------------------------- + +const logger = { + log: (msg: string, meta?: unknown) => console.log(`[INFO] ${msg}`, meta ?? ''), + warn: (msg: string, meta?: unknown) => console.warn(`[WARN] ${msg}`, meta ?? ''), + error: (msg: string, meta?: unknown) => console.error(`[ERROR] ${msg}`, meta ?? ''), + debug: (msg: string, meta?: unknown) => console.debug(`[DEBUG] ${msg}`, meta ?? ''), + verbose: (msg: string, meta?: unknown) => console.debug(`[VERB] ${msg}`, meta ?? ''), + fatal: (msg: string, meta?: unknown) => console.error(`[FATAL] ${msg}`, meta ?? ''), +}; + +// ---- adapter wiring --------------------------------------------------------- + +const http = new InfaktHttpClient({ apiKey: API_KEY, baseUrl: BASE_URL }, logger); +const adapter = new InfaktInvoicingAdapter(CONNECTION_ID, http, logger); + +// ---- test buyer profile ----------------------------------------------------- + +const testBuyer = new BuyerProfile( + 'OpenLinker Test Sp. z o.o.', + { scheme: 'pl-nip', value: '5252659437' }, + { line1: 'ul. Testowa 1', line2: null, city: 'Warszawa', postalCode: '00-001', countryIso2: 'PL' }, + 'company', +); + +// ---- helpers ---------------------------------------------------------------- + +function ok(label: string, value?: unknown): void { + console.log(` ✅ ${label}`, value !== undefined ? value : ''); +} + +function fail(label: string, err: unknown): void { + console.error(` ❌ ${label}`, err); +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollUntilCleared(invoiceUuid: string): Promise { + const deadline = Date.now() + POLL_MAX_MS; + let attempt = 0; + while (Date.now() < deadline) { + attempt++; + const result = await adapter.getClearanceStatus({ + id: invoiceUuid, + connectionId: CONNECTION_ID, + orderId: '', + providerType: 'infakt', + documentType: 'invoice', + status: 'issued', + providerInvoiceId: invoiceUuid, + providerInvoiceNumber: null, + regulatoryStatus: 'submitted', + clearanceReference: null, + idempotencyKey: null, + pdfUrl: null, + issuedAt: null, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + } as Parameters[0]); + + console.log( + ` Poll #${attempt}: regulatoryStatus=${result.regulatoryStatus}, clearanceReference=${result.clearanceReference ?? 'null'}`, + ); + + if (result.regulatoryStatus === 'cleared' || result.regulatoryStatus === 'accepted') { + return result.clearanceReference ?? null; + } + if (result.regulatoryStatus === 'rejected') { + throw new Error('KSeF returned rejected status'); + } + + await sleep(POLL_INTERVAL_MS); + } + throw new Error(`KSeF clearance poll timed out after ${POLL_MAX_MS / 1000} s`); +} + +// ---- main ------------------------------------------------------------------- + +async function main(): Promise { + console.log(''); + console.log('=== Infakt Sandbox POC ==='); + console.log(`baseUrl: ${BASE_URL}`); + console.log(''); + + // Step 1 — upsertCustomer + console.log('STEP 1 — upsertCustomer'); + let clientUuid: string; + try { + const r = await adapter.upsertCustomer({ + connectionId: CONNECTION_ID, + buyer: testBuyer, + }); + clientUuid = r.providerCustomerId; + ok('client upserted', `uuid=${clientUuid}`); + } catch (err) { + fail('upsertCustomer failed', err); + process.exit(1); + } + + // Step 2 — issueInvoice + console.log('\nSTEP 2 — issueInvoice'); + let invoiceUuid: string; + let invoiceNumber: string | null; + try { + const record = await adapter.issueInvoice({ + connectionId: CONNECTION_ID, + orderId: `poc-order-${Date.now()}`, + buyer: testBuyer, + currency: 'PLN', + documentType: 'invoice', + idempotencyKey: `poc-${Date.now()}`, + lines: [ + { + name: 'Abonament OpenLinker — POC test', + quantity: 1, + unitPriceGross: 369, // gross = 300 net × 1.23 + taxRate: '23', + }, + { + name: 'Konfiguracja integracji', + quantity: 2, + unitPriceGross: 123, // 100 net × 1.23 + taxRate: '23', + }, + ], + }); + invoiceUuid = record.providerInvoiceId!; + invoiceNumber = record.providerInvoiceNumber; + ok('invoice created', `uuid=${invoiceUuid} number=${invoiceNumber ?? 'draft'} status=${record.status}`); + ok('initial regulatoryStatus', record.regulatoryStatus); + } catch (err) { + fail('issueInvoice failed', err); + process.exit(1); + } + + // Step 3 — getInvoice by UUID + console.log('\nSTEP 3 — getInvoice (read back by UUID)'); + try { + const fetched = await adapter.getInvoice({ providerInvoiceId: invoiceUuid }); + if (!fetched) throw new Error('getInvoice returned null'); + ok('invoice fetched', `uuid=${fetched.providerInvoiceId} number=${fetched.providerInvoiceNumber ?? 'draft'}`); + ok('regulatoryStatus from getInvoice', fetched.regulatoryStatus); + } catch (err) { + fail('getInvoice failed', err); + // Non-fatal — continue + } + + // Step 4 — getClearanceStatus (before KSeF trigger) + console.log('\nSTEP 4 — getClearanceStatus (pre-submit)'); + try { + const preStatus = await adapter.getClearanceStatus({ + id: invoiceUuid, + connectionId: CONNECTION_ID, + orderId: '', + providerType: 'infakt', + documentType: 'invoice', + status: 'issued', + providerInvoiceId: invoiceUuid, + providerInvoiceNumber: invoiceNumber, + regulatoryStatus: 'not-applicable', + clearanceReference: null, + idempotencyKey: null, + pdfUrl: null, + issuedAt: null, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + } as Parameters[0]); + ok('pre-submit clearance', `status=${preStatus.regulatoryStatus} ref=${preStatus.clearanceReference ?? 'null'}`); + } catch (err) { + fail('getClearanceStatus (pre) failed', err); + } + + // Step 5 — trigger KSeF (Infakt-specific, not in port) + console.log('\nSTEP 5 — sendToKsef (trigger KSeF submission)'); + let ksefResponse: InfaktSendToKsefResponse; + try { + ksefResponse = await adapter.sendToKsef(invoiceUuid); + ok('send_to_ksef accepted', `status=${ksefResponse.status} request_uuid=${ksefResponse.request_uuid}`); + } catch (err) { + fail('sendToKsef failed', err); + console.log(' → skipping KSeF poll (KSeF may not be configured on this account)'); + console.log('\n=== POC DONE (no KSeF) ==='); + console.log('issueInvoice + getInvoice + getClearanceStatus: CONFIRMED ✅'); + return; + } + + // Step 6 — poll until cleared + console.log(`\nSTEP 6 — polling KSeF clearance (max ${POLL_MAX_MS / 1000} s, every ${POLL_INTERVAL_MS / 1000} s)`); + try { + const ksefNumber = await pollUntilCleared(invoiceUuid); + ok('KSeF CLEARED', `ksef_number=${ksefNumber ?? 'n/a'}`); + } catch (err) { + fail('KSeF clearance failed or timed out', err); + process.exit(1); + } + + console.log(''); + console.log('=== POC COMPLETE ✅ ==='); + console.log('All steps confirmed:'); + console.log(' upsertCustomer → issueInvoice → getInvoice → getClearanceStatus → sendToKsef → cleared'); +} + +main().catch((err: unknown) => { + console.error('Unexpected error:', err); + process.exit(1); +}); diff --git a/libs/integrations/infakt/src/testing/fake-infakt-http-client.ts b/libs/integrations/infakt/src/testing/fake-infakt-http-client.ts new file mode 100644 index 000000000..5acc16841 --- /dev/null +++ b/libs/integrations/infakt/src/testing/fake-infakt-http-client.ts @@ -0,0 +1,68 @@ +/** + * Fake Infakt HTTP Client — test double + * + * In-memory `IInfaktHttpClient` for adapter unit specs: seed a response (or a + * rejection) per `METHOD path` key and play it back, recording calls for + * assertions. No real `fetch`, no auth headers, no retries. + * + * Consumed only from `*.spec.ts`. Kept off the main barrel — importing it + * from runtime code would pull test-only logic into the bundle. + * + * @module libs/integrations/infakt/src/testing + */ +import type { IInfaktHttpClient } from '../infrastructure/http/infakt-http-client.interface'; + +interface RecordedCall { + method: 'GET' | 'POST'; + path: string; + query?: Record; + body?: unknown; +} + +type SeededResult = { kind: 'resolve'; value: T } | { kind: 'reject'; error: Error }; + +export class FakeInfaktHttpClient implements IInfaktHttpClient { + readonly calls: RecordedCall[] = []; + private readonly responses = new Map>(); + + /** Seed a successful JSON response for `GET path` / `POST path`. */ + seed(method: 'GET' | 'POST', path: string, value: T): this { + this.responses.set(this.key(method, path), { kind: 'resolve', value }); + return this; + } + + /** Seed a rejection (e.g. `InfaktApiError`) for `GET path` / `POST path`. */ + seedError(method: 'GET' | 'POST', path: string, error: Error): this { + this.responses.set(this.key(method, path), { kind: 'reject', error }); + return this; + } + + clear(): void { + this.calls.length = 0; + this.responses.clear(); + } + + get(path: string, query?: Record): Promise { + this.calls.push({ method: 'GET', path, query }); + return this.resolve('GET', path); + } + + post(path: string, body?: unknown): Promise { + this.calls.push({ method: 'POST', path, body }); + return this.resolve('POST', path); + } + + private resolve(method: 'GET' | 'POST', path: string): Promise { + const seeded = this.responses.get(this.key(method, path)); + if (!seeded) { + return Promise.reject( + new Error(`FakeInfaktHttpClient: no response seeded for ${method} ${path}`), + ); + } + return seeded.kind === 'resolve' ? Promise.resolve(seeded.value as T) : Promise.reject(seeded.error); + } + + private key(method: 'GET' | 'POST', path: string): string { + return `${method} ${path}`; + } +} diff --git a/libs/integrations/infakt/tsconfig.json b/libs/integrations/infakt/tsconfig.json new file mode 100644 index 000000000..c28cba996 --- /dev/null +++ b/libs/integrations/infakt/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../../core" }, + { "path": "../../shared" }, + { "path": "../../plugin-sdk" } + ] +} diff --git a/libs/integrations/infakt/tsconfig.spec.json b/libs/integrations/infakt/tsconfig.spec.json new file mode 100644 index 000000000..11656c3f7 --- /dev/null +++ b/libs/integrations/infakt/tsconfig.spec.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": ["src/**/*.spec.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5023b41f..16b0102ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,6 +706,46 @@ importers: specifier: 5.4.3 version: 5.4.3 + libs/integrations/infakt: + dependencies: + '@nestjs/common': + specifier: ^10.0.0 + version: 10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@openlinker/core': + specifier: workspace:* + version: link:../../core + '@openlinker/plugin-sdk': + specifier: workspace:* + version: link:../../plugin-sdk + '@openlinker/shared': + specifier: workspace:* + version: link:../../shared + devDependencies: + '@types/jest': + specifier: 29.5.12 + version: 29.5.12 + '@types/node': + specifier: 20.11.30 + version: 20.11.30 + '@typescript-eslint/eslint-plugin': + specifier: 7.3.1 + version: 7.3.1(@typescript-eslint/parser@7.3.1(eslint@8.57.0)(typescript@5.4.3))(eslint@8.57.0)(typescript@5.4.3) + '@typescript-eslint/parser': + specifier: 7.3.1 + version: 7.3.1(eslint@8.57.0)(typescript@5.4.3) + eslint: + specifier: 8.57.0 + version: 8.57.0 + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.11.30)(ts-node@10.9.2(@types/node@20.11.30)(typescript@5.4.3)) + ts-jest: + specifier: 29.1.2 + version: 29.1.2(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest@29.7.0(@types/node@20.11.30)(ts-node@10.9.2(@types/node@20.11.30)(typescript@5.4.3)))(typescript@5.4.3) + typescript: + specifier: 5.4.3 + version: 5.4.3 + libs/integrations/inpost: dependencies: '@nestjs/common': From 17267c9a4df6b2d4489dbbd409a5d103113213d6 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 13:36:35 +0200 Subject: [PATCH 2/5] fix(integrations): address PR #1292 review findings on Infakt adapter - Resolve invoice_type from InvoiceRecord.documentType in getClearanceStatus, and try vat then corrective in getInvoice, instead of a dead-constant helper pair that always evaluated to 'vat' (was returning wrong/404 data for correction lookups) - Round unit_net_price/corrPrice to 2 decimals (toFixed) to avoid raw floating-point strings like "100.00000000000001" in the Infakt payload - Move InfaktCredentials/InfaktConnectionConfig into domain/types/infakt-connection.types.ts, mirroring the KSeF plugin - Add IInfaktAdapterFactory interface, implemented by InfaktAdapterFactory - Drop the no-op try/catch wrapper in createCapabilityAdapter Co-Authored-By: Claude Sonnet 5 Signed-off-by: norbert-kulus-blockydevs --- .../src/application/infakt-adapter.factory.ts | 12 +-- .../infakt-adapter.factory.interface.ts | 23 +++++ .../domain/types/infakt-connection.types.ts | 20 +++++ libs/integrations/infakt/src/infakt-plugin.ts | 24 ++--- .../adapters/infakt-invoicing.adapter.ts | 89 ++++++++++--------- 5 files changed, 103 insertions(+), 65 deletions(-) create mode 100644 libs/integrations/infakt/src/application/interfaces/infakt-adapter.factory.interface.ts create mode 100644 libs/integrations/infakt/src/domain/types/infakt-connection.types.ts diff --git a/libs/integrations/infakt/src/application/infakt-adapter.factory.ts b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts index 1b3f1e2e6..2ad5f52a2 100644 --- a/libs/integrations/infakt/src/application/infakt-adapter.factory.ts +++ b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts @@ -11,16 +11,10 @@ import type { Connection } from '@openlinker/core/identifier-mapping'; import type { CredentialsResolverPort } from '@openlinker/core/integrations'; import { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from '../infrastructure/http/infakt-http-client'; import { InfaktInvoicingAdapter } from '../infrastructure/adapters/infakt-invoicing.adapter'; +import type { IInfaktAdapterFactory } from './interfaces/infakt-adapter.factory.interface'; +import type { InfaktCredentials, InfaktConnectionConfig } from '../domain/types/infakt-connection.types'; -interface InfaktCredentials { - apiKey: string; -} - -interface InfaktConnectionConfig { - baseUrl?: string; -} - -export class InfaktAdapterFactory { +export class InfaktAdapterFactory implements IInfaktAdapterFactory { async createInvoicingAdapter( connection: Connection, credentialsResolver: CredentialsResolverPort, diff --git a/libs/integrations/infakt/src/application/interfaces/infakt-adapter.factory.interface.ts b/libs/integrations/infakt/src/application/interfaces/infakt-adapter.factory.interface.ts new file mode 100644 index 000000000..2e92884ab --- /dev/null +++ b/libs/integrations/infakt/src/application/interfaces/infakt-adapter.factory.interface.ts @@ -0,0 +1,23 @@ +/** + * Infakt Adapter Factory Interface + * + * Contract for the single per-connection construction seam of the Infakt + * plugin: resolve a connection's credentials and build the `Invoicing` + * capability adapter. Mirrors the `IKsefAdapterFactory` / `IErliAdapterFactory` + * precedent so consumers depend on the abstraction rather than the concrete + * factory class. + * + * @module libs/integrations/infakt/src/application/interfaces + */ +import type { LoggerPort } from '@openlinker/shared/logging'; +import type { Connection } from '@openlinker/core/identifier-mapping'; +import type { CredentialsResolverPort } from '@openlinker/core/integrations'; +import type { InfaktInvoicingAdapter } from '../../infrastructure/adapters/infakt-invoicing.adapter'; + +export interface IInfaktAdapterFactory { + createInvoicingAdapter( + connection: Connection, + credentialsResolver: CredentialsResolverPort, + logger: LoggerPort, + ): Promise; +} diff --git a/libs/integrations/infakt/src/domain/types/infakt-connection.types.ts b/libs/integrations/infakt/src/domain/types/infakt-connection.types.ts new file mode 100644 index 000000000..3fa9f1673 --- /dev/null +++ b/libs/integrations/infakt/src/domain/types/infakt-connection.types.ts @@ -0,0 +1,20 @@ +/** + * Infakt Connection Types + * + * Per-connection non-secret config + credentials shapes for the Infakt plugin. + * Mirrors the sibling KSeF plugin's `ksef-connection.types.ts` layout — kept + * out of the factory file per engineering-standards § Type Definitions in + * Separate Files. + * + * @module libs/integrations/infakt/src/domain/types + */ + +/** Credentials shape resolved via `CredentialsResolverPort`. */ +export interface InfaktCredentials { + apiKey: string; +} + +/** Non-secret config persisted on the connection row. */ +export interface InfaktConnectionConfig { + baseUrl?: string; +} diff --git a/libs/integrations/infakt/src/infakt-plugin.ts b/libs/integrations/infakt/src/infakt-plugin.ts index 120811073..2c0bd7f0a 100644 --- a/libs/integrations/infakt/src/infakt-plugin.ts +++ b/libs/integrations/infakt/src/infakt-plugin.ts @@ -65,22 +65,14 @@ export function createInfaktPlugin(): AdapterPlugin { capability: string, host: HostServices, ): Promise { - try { - const logger = new Logger(`Infakt:${connection.id}`); - const factory = new InfaktAdapterFactory(); - const invoicingAdapter = await factory.createInvoicingAdapter( - connection, - host.credentialsResolver, - logger, - ); - return dispatchCapability( - capability, - { Invoicing: () => invoicingAdapter }, - INFAKT_BRAND, - ); - } catch (err) { - return Promise.reject(err as Error); - } + const logger = new Logger(`Infakt:${connection.id}`); + const factory = new InfaktAdapterFactory(); + const invoicingAdapter = await factory.createInvoicingAdapter( + connection, + host.credentialsResolver, + logger, + ); + return dispatchCapability(capability, { Invoicing: () => invoicingAdapter }, INFAKT_BRAND); }, }; } 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 1229bfe2f..1fb50e7a4 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -62,6 +62,18 @@ function toRegulatoryStatus(ksefStatus: InfaktKsefStatus | null | undefined): Re } } +/** Maps neutral DocumentType → Infakt's GET-by-uuid `invoice_type` query param. */ +function toInfaktInvoiceType(documentType: string): string { + switch (documentType) { + case 'corrected': + return 'corrective'; + case 'proforma': + return 'proforma'; + default: + return 'vat'; + } +} + /** Maps neutral taxRate string to Infakt tax_symbol. */ function toInfaktTaxSymbol(taxRate: string): string { // Common neutral→Infakt mapping; adapter owns this PL logic @@ -143,7 +155,7 @@ export class InfaktInvoicingAdapter tax_symbol: toInfaktTaxSymbol(l.taxRate), quantity: l.quantity, unit: 'szt.', - unit_net_price: `${l.unitPriceGross / (1 + this.taxRateNumeric(l.taxRate))} ${currency ?? 'PLN'}`, + unit_net_price: `${(l.unitPriceGross / (1 + this.taxRateNumeric(l.taxRate))).toFixed(2)} ${currency ?? 'PLN'}`, })); const payload = { @@ -193,34 +205,39 @@ export class InfaktInvoicingAdapter return null; } - try { - const invoice = await this.http.get( - `invoices/${providerInvoiceId}.json`, - { invoice_type: invoice_kind_to_type(invoice_uuid_kind(providerInvoiceId)) }, - ); - const now = new Date(); - return new InvoiceRecord( - randomUUID(), - this.connectionId, - '', - INFAKT_PROVIDER_TYPE, - invoice.kind === 'corrective' ? 'corrected' : 'invoice', - 'issued', - invoice.uuid, - invoice.number ?? null, - toRegulatoryStatus(invoice.ksef_data?.status ?? null), - invoice.ksef_data?.ksef_number ?? null, - null, - invoice.pdf_url ?? null, - invoice.invoice_date ? new Date(invoice.invoice_date) : now, - null, - now, - now, - ); - } catch (err) { - if (err instanceof InfaktApiError && err.statusCode === 404) return null; - throw err; + // Kind is unknown ahead of the lookup (no InvoiceRecord to read documentType + // from); try the two kinds this adapter issues (`vat`, `corrective`) in turn. + for (const invoiceType of ['vat', 'corrective']) { + try { + const invoice = await this.http.get( + `invoices/${providerInvoiceId}.json`, + { invoice_type: invoiceType }, + ); + const now = new Date(); + return new InvoiceRecord( + randomUUID(), + this.connectionId, + '', + INFAKT_PROVIDER_TYPE, + invoice.kind === 'corrective' ? 'corrected' : 'invoice', + 'issued', + invoice.uuid, + invoice.number ?? null, + toRegulatoryStatus(invoice.ksef_data?.status ?? null), + invoice.ksef_data?.ksef_number ?? null, + null, + invoice.pdf_url ?? null, + invoice.invoice_date ? new Date(invoice.invoice_date) : now, + null, + now, + now, + ); + } catch (err) { + if (err instanceof InfaktApiError && err.statusCode === 404) continue; + throw err; + } } + return null; } async getClearanceStatus(record: InvoiceRecord): Promise { @@ -230,7 +247,7 @@ export class InfaktInvoicingAdapter const invoice = await this.http.get( `invoices/${record.providerInvoiceId}.json`, - { invoice_type: 'vat' }, + { invoice_type: toInfaktInvoiceType(record.documentType) }, ); const ksefData = invoice.ksef_data; @@ -255,8 +272,8 @@ export class InfaktInvoicingAdapter const corrLine = lines.find((l) => l.originalLineNumber === idx + 1); const corrQty = corrLine?.newQuantity ?? svc.quantity; const corrPrice = corrLine?.newUnitPriceGross - ? `${corrLine.newUnitPriceGross} PLN` - : `${svc.unit_net_price} PLN`; + ? `${corrLine.newUnitPriceGross.toFixed(2)} PLN` + : `${svc.unit_net_price.toFixed(2)} PLN`; return [ // Original "before" row { @@ -264,7 +281,7 @@ export class InfaktInvoicingAdapter tax_symbol: svc.tax_symbol, quantity: svc.quantity, unit: svc.unit ?? 'szt.', - unit_net_price: `${svc.unit_net_price} PLN`, + unit_net_price: `${svc.unit_net_price.toFixed(2)} PLN`, group: idx + 1, correction: false, }, @@ -353,11 +370,3 @@ export class InfaktInvoicingAdapter return 0; } } - -// Infakt requires invoice_type query param on GET; for POC we default to 'vat' -function invoice_uuid_kind(_uuid: string): string { - return 'vat'; -} -function invoice_kind_to_type(kind: string): string { - return kind === 'corrective' ? 'corrective' : 'vat'; -} From 48b8e23cd8935a5e4a0d8d56bc3c94a7adce83ac Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 17:20:50 +0200 Subject: [PATCH 3/5] fix(integrations): address round-2 PR #1292 review findings on Infakt correction path Fixes a fiscal-correctness bug: issueCorrection wrote a gross price straight into Infakt's net-only unit_net_price field for price-changing correction lines, overstating the corrected amount by the tax fraction. Extracts the gross->net conversion issueInvoice already used into a shared grossToNet helper and reuses it here. Also addresses the review's suggestions: moves the POC sandbox script out of src/ so it no longer compiles into dist, stops logging the raw Infakt error response body (can carry buyer PII), and replaces the factory's bare Error on a missing credentialsRef with a domain InfaktConfigException (matching the DPD/KSeF precedent). Signed-off-by: norbert-kulus-blockydevs --- libs/integrations/infakt/package.json | 4 ++- .../{src => }/scripts/poc-sandbox-test.ts | 11 +++--- .../src/application/infakt-adapter.factory.ts | 6 +++- .../exceptions/infakt-config.exception.ts | 22 ++++++++++++ libs/integrations/infakt/src/index.ts | 1 + .../infakt-invoicing.adapter.spec.ts | 19 ++++++++++ .../adapters/infakt-invoicing.adapter.ts | 36 +++++++++++++------ .../infrastructure/http/infakt-http-client.ts | 5 ++- 8 files changed, 86 insertions(+), 18 deletions(-) rename libs/integrations/infakt/{src => }/scripts/poc-sandbox-test.ts (94%) create mode 100644 libs/integrations/infakt/src/domain/exceptions/infakt-config.exception.ts diff --git a/libs/integrations/infakt/package.json b/libs/integrations/infakt/package.json index 55332f724..a0300813f 100644 --- a/libs/integrations/infakt/package.json +++ b/libs/integrations/infakt/package.json @@ -24,7 +24,7 @@ "test:cov": "jest --coverage --config ./jest.config.mjs", "lint": "eslint \"src/**/*.ts\" --fix", "type-check": "tsc --noEmit", - "poc:sandbox": "ts-node -r tsconfig-paths/register src/scripts/poc-sandbox-test.ts" + "poc:sandbox": "ts-node -r tsconfig-paths/register scripts/poc-sandbox-test.ts" }, "dependencies": { "@openlinker/core": "workspace:*", @@ -45,6 +45,8 @@ "eslint": "8.57.0", "jest": "29.7.0", "ts-jest": "29.1.2", + "ts-node": "10.9.2", + "tsconfig-paths": "4.2.0", "typescript": "5.4.3" } } diff --git a/libs/integrations/infakt/src/scripts/poc-sandbox-test.ts b/libs/integrations/infakt/scripts/poc-sandbox-test.ts similarity index 94% rename from libs/integrations/infakt/src/scripts/poc-sandbox-test.ts rename to libs/integrations/infakt/scripts/poc-sandbox-test.ts index 6fbb50b35..12371c42f 100644 --- a/libs/integrations/infakt/src/scripts/poc-sandbox-test.ts +++ b/libs/integrations/infakt/scripts/poc-sandbox-test.ts @@ -10,7 +10,7 @@ * 6. pollUntilCleared — poll getClearanceStatus until success/error (max 3 min) * * Usage: - * INFAKT_SANDBOX_API_KEY= npx ts-node src/scripts/poc-sandbox-test.ts + * INFAKT_SANDBOX_API_KEY= pnpm --filter @openlinker/integrations-infakt poc:sandbox * * Optional env: * INFAKT_BASE_URL — defaults to https://api.sandbox-infakt.pl/api/v3 @@ -18,13 +18,16 @@ * POC_POLL_INTERVAL_MS — poll interval (default 10000 = 10 s) * * Verified live 2026-06-30: full draft→KSeF clearance in ~90 s on sandbox. + * + * Lives outside src/ (package `include` is `src/**\/*`) so it never compiles + * into dist or ships to consumers of this package. */ /* eslint-disable no-console -- POC script intentionally uses console for output */ -import { InfaktHttpClient } from '../infrastructure/http/infakt-http-client'; -import { InfaktInvoicingAdapter } from '../infrastructure/adapters/infakt-invoicing.adapter'; -import type { InfaktSendToKsefResponse } from '../domain/types/infakt.types'; +import { InfaktHttpClient } from '../src/infrastructure/http/infakt-http-client'; +import { InfaktInvoicingAdapter } from '../src/infrastructure/adapters/infakt-invoicing.adapter'; +import type { InfaktSendToKsefResponse } from '../src/domain/types/infakt.types'; import { BuyerProfile } from '@openlinker/core/invoicing'; // ---- config ---------------------------------------------------------------- diff --git a/libs/integrations/infakt/src/application/infakt-adapter.factory.ts b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts index 2ad5f52a2..fa86a46ca 100644 --- a/libs/integrations/infakt/src/application/infakt-adapter.factory.ts +++ b/libs/integrations/infakt/src/application/infakt-adapter.factory.ts @@ -11,6 +11,7 @@ import type { Connection } from '@openlinker/core/identifier-mapping'; import type { CredentialsResolverPort } from '@openlinker/core/integrations'; import { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from '../infrastructure/http/infakt-http-client'; import { InfaktInvoicingAdapter } from '../infrastructure/adapters/infakt-invoicing.adapter'; +import { InfaktConfigException } from '../domain/exceptions/infakt-config.exception'; import type { IInfaktAdapterFactory } from './interfaces/infakt-adapter.factory.interface'; import type { InfaktCredentials, InfaktConnectionConfig } from '../domain/types/infakt-connection.types'; @@ -26,7 +27,10 @@ export class InfaktAdapterFactory implements IInfaktAdapterFactory { const creds = raw as InfaktCredentials; apiKey = creds.apiKey; } else { - throw new Error(`Infakt connection ${connection.id} has no credentialsRef`); + throw new InfaktConfigException( + `Infakt connection ${connection.id} has no credentialsRef`, + connection.id, + ); } const config = (connection.config ?? {}) as InfaktConnectionConfig; diff --git a/libs/integrations/infakt/src/domain/exceptions/infakt-config.exception.ts b/libs/integrations/infakt/src/domain/exceptions/infakt-config.exception.ts new file mode 100644 index 000000000..573041966 --- /dev/null +++ b/libs/integrations/infakt/src/domain/exceptions/infakt-config.exception.ts @@ -0,0 +1,22 @@ +/** + * Infakt Config Exception + * + * Thrown when a connection's config or credentials are invalid or missing + * required fields (no `credentialsRef`, malformed `apiKey`, …). Distinct + * from `InfaktApiError`, which covers rejections returned by the Infakt API + * itself. + * + * @module libs/integrations/infakt/src/domain/exceptions + */ +export class InfaktConfigException extends Error { + constructor( + message: string, + public readonly connectionId?: string, + ) { + super(message); + this.name = 'InfaktConfigException'; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, InfaktConfigException); + } + } +} diff --git a/libs/integrations/infakt/src/index.ts b/libs/integrations/infakt/src/index.ts index 974dfcf3d..cd68b38fa 100644 --- a/libs/integrations/infakt/src/index.ts +++ b/libs/integrations/infakt/src/index.ts @@ -8,6 +8,7 @@ export type { InfaktWebhookEvent, InfaktWebhookEventName, InfaktWebhookTranslato export { InfaktInvoicingAdapter, INFAKT_PROVIDER_TYPE } from './infrastructure/adapters/infakt-invoicing.adapter'; export { InfaktHttpClient, INFAKT_DEFAULT_BASE_URL } from './infrastructure/http/infakt-http-client'; export { InfaktApiError } from './domain/exceptions/infakt-api.error'; +export { InfaktConfigException } from './domain/exceptions/infakt-config.exception'; export type { InfaktInvoice, InfaktClient, InfaktKsefData, InfaktKsefStatus } from './domain/types/infakt.types'; // Shape validators + retry classifier — exported so host-side tests can 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 71d9acf29..4eb7841c9 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 @@ -426,6 +426,25 @@ describe('InfaktInvoicingAdapter', () => { expect(record.idempotencyKey).toBe('idem-corr-1'); }); + 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' })); + + await adapter.issueCorrection({ + ...baseCmd, + lines: [{ originalLineNumber: 1, newUnitPriceGross: 61.5 }], + }); + + 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 }[] }; + }; + 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'); + }); + it('should propagate a 422 InfaktApiError with failureMode: rejected (error path)', async () => { http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); http.seedError( 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 1fb50e7a4..4bed75843 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -100,6 +100,19 @@ function toInfaktTaxSymbol(taxRate: string): string { } } +/** Parses a tax-rate string (neutral `'23'`/`'0.23'` or Infakt `tax_symbol` `'zw'`/`'np'`) to a decimal fraction. */ +function taxRateNumeric(taxRate: string): number { + const n = parseFloat(taxRate); + if (!isNaN(n) && n > 1) return n / 100; + if (!isNaN(n)) return n; + return 0; +} + +/** Converts a buyer-paid gross unit price to Infakt's net unit price for the given tax rate. */ +function grossToNet(unitPriceGross: number, taxRate: string): number { + return unitPriceGross / (1 + taxRateNumeric(taxRate)); +} + export class InfaktInvoicingAdapter implements InvoicingPort, RegulatoryStatusReader, CorrectionIssuer { @@ -155,7 +168,7 @@ export class InfaktInvoicingAdapter tax_symbol: toInfaktTaxSymbol(l.taxRate), quantity: l.quantity, unit: 'szt.', - unit_net_price: `${(l.unitPriceGross / (1 + this.taxRateNumeric(l.taxRate))).toFixed(2)} ${currency ?? 'PLN'}`, + unit_net_price: `${grossToNet(l.unitPriceGross, l.taxRate).toFixed(2)} ${currency ?? 'PLN'}`, })); const payload = { @@ -267,13 +280,21 @@ 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; + // 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 - ? `${corrLine.newUnitPriceGross.toFixed(2)} PLN` - : `${svc.unit_net_price.toFixed(2)} PLN`; + ? `${grossToNet(corrLine.newUnitPriceGross, svc.tax_symbol).toFixed(2)} ${currency}` + : `${svc.unit_net_price.toFixed(2)} ${currency}`; return [ // Original "before" row { @@ -281,7 +302,7 @@ export class InfaktInvoicingAdapter tax_symbol: svc.tax_symbol, quantity: svc.quantity, unit: svc.unit ?? 'szt.', - unit_net_price: `${svc.unit_net_price.toFixed(2)} PLN`, + unit_net_price: `${svc.unit_net_price.toFixed(2)} ${currency}`, group: idx + 1, correction: false, }, @@ -362,11 +383,4 @@ export class InfaktInvoicingAdapter return null; } } - - private taxRateNumeric(taxRate: string): number { - const n = parseFloat(taxRate); - if (!isNaN(n) && n > 1) return n / 100; - if (!isNaN(n)) return n; - return 0; - } } diff --git a/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts index 956acbae5..069f229e0 100644 --- a/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts +++ b/libs/integrations/infakt/src/infrastructure/http/infakt-http-client.ts @@ -68,7 +68,10 @@ export class InfaktHttpClient implements IInfaktHttpClient { ); } if (!res.ok) { - this.logger.warn(`Infakt API ${method} ${path} → ${res.status}`, { body: text }); + // Don't log the response body — Infakt error payloads can echo back + // buyer PII (name, NIP, address) submitted in the request. The full + // body still reaches the caller via InfaktApiError.responseBody. + this.logger.warn(`Infakt API ${method} ${path} → ${res.status}`); throw new InfaktApiError( `Infakt API ${method} ${path} failed with status ${res.status}`, res.status, From 878d0e83afcc3b66343800c91dfc568d967210ba Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 17:23:43 +0200 Subject: [PATCH 4/5] chore(integrations): update pnpm-lock.yaml for infakt ts-node/tsconfig-paths devDeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the previous commit's new devDependencies (needed to run the relocated poc:sandbox script) — CI's frozen-lockfile install was failing without this. Signed-off-by: norbert-kulus-blockydevs --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16b0102ba..48028bed4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,6 +742,12 @@ importers: ts-jest: specifier: 29.1.2 version: 29.1.2(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest@29.7.0(@types/node@20.11.30)(ts-node@10.9.2(@types/node@20.11.30)(typescript@5.4.3)))(typescript@5.4.3) + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@20.11.30)(typescript@5.4.3) + tsconfig-paths: + specifier: 4.2.0 + version: 4.2.0 typescript: specifier: 5.4.3 version: 5.4.3 From 3d374fec7144324a631d6e3f6474f87a923290b5 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Wed, 1 Jul 2026 20:10:52 +0200 Subject: [PATCH 5/5] fix(integrations): fix Infakt unit_net_price type mismatch on correction path Address the non-blocking note from the PR #1292 re-review: Infakt's v3 schema documents unit_net_price/net_price/tax_price/gross_price as "amount currency" strings (e.g. "100.00 PLN"), not plain numbers. issueCorrection called .toFixed(2) on svc.unit_net_price assuming it was a number, which would throw at runtime against the real API. Corrected the wire types to string and added a parseInfaktAmount helper to read the value back before formatting. Signed-off-by: norbert-kulus-blockydevs --- .../infakt/src/domain/types/infakt.types.ts | 17 +++++---- .../infakt-invoicing.adapter.spec.ts | 35 +++++++++++++++---- .../adapters/infakt-invoicing.adapter.ts | 14 ++++++-- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/libs/integrations/infakt/src/domain/types/infakt.types.ts b/libs/integrations/infakt/src/domain/types/infakt.types.ts index c80bd7785..674b3adb7 100644 --- a/libs/integrations/infakt/src/domain/types/infakt.types.ts +++ b/libs/integrations/infakt/src/domain/types/infakt.types.ts @@ -47,9 +47,11 @@ export interface InfaktInvoice { number: string | null; kind: InfaktInvoiceKind; status: string; - gross_price: number; - net_price: number; - tax_price: number; + // 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; payment_method: string; invoice_date: string | null; sale_date: string | null; @@ -73,10 +75,11 @@ export interface InfaktInvoiceService { tax_symbol: string; quantity: number; unit: string | null; - unit_net_price: number; - net_price: number; - tax_price: number; - gross_price: number; + // Same "amount currency" string format as InfaktInvoice's top-level totals. + unit_net_price: string; + net_price: string; + tax_price: string; + gross_price: string; 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 4eb7841c9..a004cd7ee 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, - net_price: 100, - tax_price: 23, + gross_price: '123.00 PLN', + net_price: '100.00 PLN', + tax_price: '23.00 PLN', 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, - net_price: 100, - tax_price: 23, - gross_price: 123, + unit_net_price: '100.00 PLN', + net_price: '100.00 PLN', + tax_price: '23.00 PLN', + gross_price: '123.00 PLN', correction: null, group: null, }, @@ -426,6 +426,27 @@ describe('InfaktInvoicingAdapter', () => { expect(record.idempotencyKey).toBe('idem-corr-1'); }); + 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. + http.seed('GET', 'invoices/inv-uuid-1.json', invoiceFixture()); + http.seed('POST', 'invoices.json', invoiceFixture({ uuid: 'corr-uuid-1', kind: 'corrective' })); + + await adapter.issueCorrection(baseCmd); + + 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 }[] }; + }; + expect(body.invoice.services.find((s) => s.correction === false)?.unit_net_price).toBe( + '100.00 PLN', + ); + expect(body.invoice.services.find((s) => s.correction === true)?.unit_net_price).toBe( + '100.00 PLN', + ); + }); + 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' })); 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 4bed75843..f608c3a5d 100644 --- a/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts +++ b/libs/integrations/infakt/src/infrastructure/adapters/infakt-invoicing.adapter.ts @@ -113,6 +113,12 @@ 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; +} + export class InfaktInvoicingAdapter implements InvoicingPort, RegulatoryStatusReader, CorrectionIssuer { @@ -289,12 +295,16 @@ export class InfaktInvoicingAdapter 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); // 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}` - : `${svc.unit_net_price.toFixed(2)} ${currency}`; + : `${originalNet.toFixed(2)} ${currency}`; return [ // Original "before" row { @@ -302,7 +312,7 @@ export class InfaktInvoicingAdapter tax_symbol: svc.tax_symbol, quantity: svc.quantity, unit: svc.unit ?? 'szt.', - unit_net_price: `${svc.unit_net_price.toFixed(2)} ${currency}`, + unit_net_price: `${originalNet.toFixed(2)} ${currency}`, group: idx + 1, correction: false, },