Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@openlinker/integrations-allegro": "workspace:*",
"@openlinker/integrations-dpd-polska": "workspace:*",
"@openlinker/integrations-erli": "workspace:*",
"@openlinker/integrations-infakt": "workspace:*",
"@openlinker/integrations-ksef": "workspace:*",
"@openlinker/integrations-inpost": "workspace:*",
"@openlinker/integrations-prestashop": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ import { ApiProperty } from '@nestjs/swagger';
import type {
InvoiceFailureCode,
InvoiceFailureMode,
} from '@openlinker/core/invoicing';

InvoiceRecord} from '@openlinker/core/invoicing';
import {
InvoiceFailureCodeValues,
InvoiceFailureModeValues,
InvoiceRecord,
InvoiceStatus,
InvoiceStatusValues,
RegulatoryStatus,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { WooCommerceIntegrationModule } from '@openlinker/integrations-woocommer
import { ErliIntegrationModule } from '@openlinker/integrations-erli';
import { KsefIntegrationModule } from '@openlinker/integrations-ksef';
import { SubiektIntegrationModule } from '@openlinker/integrations-subiekt';
import { InfaktIntegrationModule } from '@openlinker/integrations-infakt';

export const apiPlugins: PluginEntry[] = [
PrestashopIntegrationModule,
Expand All @@ -49,4 +50,6 @@ export const apiPlugins: PluginEntry[] = [
// #753: Subiekt nexo invoicing adapter — registered so the host can resolve
// the 'Invoicing' capability for subiekt connections.
SubiektIntegrationModule,
// #1281: Infakt accounting invoicing adapter (KSeF submitted via Infakt).
InfaktIntegrationModule,
];
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ export interface IWebhookService {
* Orchestrates the complete webhook processing flow:
* 1. Resolve the per-provider decoder (default = OL-HMAC + WebhookRequestDto)
* 2. Connection gate (exists, active, platformType matches)
* 3. Verify signature via the decoder; replay-check the normalized timestamp
* 4. Decode the body → route | ignore (202, no publish) | reject (400)
* 5. Dedup gate → publish event → record delivery
* 3. Subscription-verification handshake — if detected, return its echo
* body immediately (no verify/dedup/publish)
* 4. Verify signature via the decoder; replay-check the normalized timestamp
* 5. Decode the body → route | ignore (202, no publish) | reject (400)
* 6. Dedup gate → publish event → record delivery
*
* @param provider - Provider identifier (e.g., 'prestashop', 'inpost')
* @param connectionId - Connection identifier (UUID)
* @param rawBody - Raw request body bytes (the decoder verifies + parses these)
* @param headers - Request headers (provider-specific signature/timestamp/topic)
* @returns the handshake echo body when the request is a subscription
* verification ping; otherwise resolves with no value
* @throws WebhookAuthenticationException if signature/connection is invalid (401)
* @throws WebhookReplayException if timestamp is out of window (401)
* @throws WebhookDecodeException if the body can't be decoded (400)
Expand All @@ -32,5 +36,5 @@ export interface IWebhookService {
connectionId: string,
rawBody: Buffer,
headers: Record<string, string>
): Promise<void>;
): Promise<Record<string, unknown> | void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,40 @@ describe('WebhookService (ADR-021 decoder dispatch + #711 dedup/recovery)', () =
service = module.get(WebhookService);
});

describe('subscription-verification handshake', () => {
it('returns the handshake echo body and short-circuits before verify/dedup/publish', async () => {
const handshakeDecoder: jest.Mocked<InboundWebhookDecoderPort> = {
...decoder,
detectHandshake: jest.fn().mockReturnValue({ verification_code: 'abc123' }),
};
decoderRegistry.get.mockReturnValue(handshakeDecoder);

const result = await service.processWebhook(provider, connectionId, rawBody, headers);

expect(result).toEqual({ verification_code: 'abc123' });
expect(handshakeDecoder.verify).not.toHaveBeenCalled();
expect(deliveryRepository.insertIfNew).not.toHaveBeenCalled();
expect(eventPublisher.publishInboundWebhook).not.toHaveBeenCalled();
});

it('proceeds to verify when detectHandshake returns null', async () => {
const nonHandshakeDecoder: jest.Mocked<InboundWebhookDecoderPort> = {
...decoder,
detectHandshake: jest.fn().mockReturnValue(null),
};
decoderRegistry.get.mockReturnValue(nonHandshakeDecoder);
deliveryRepository.insertIfNew.mockResolvedValue({
isNew: true,
delivery: makeDelivery({ eventId: 'e1', provider, connectionId }),
});

const result = await service.processWebhook(provider, connectionId, rawBody, headers);

expect(result).toBeUndefined();
expect(nonHandshakeDecoder.verify).toHaveBeenCalled();
});
});

describe('decoder dispatch + three-state decode', () => {
it('publishes a routed event when the decoder verifies + routes and the row is new', async () => {
deliveryRepository.insertIfNew.mockResolvedValue({
Expand Down
16 changes: 14 additions & 2 deletions apps/api/src/webhooks/application/services/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DefaultWebhookDecoder } from '../decoders/default-webhook-decoder';
import { WebhookAuthenticationException } from '../errors/webhook-authentication.exception';
import { WebhookDecodeException } from '../errors/webhook-decode.exception';
import type { InboundWebhookEvent } from '@openlinker/core/events';
import type { InboundWebhookDecoderPort } from '@openlinker/core/integrations';
import {
InboundWebhookDecoderRegistryService,
INBOUND_WEBHOOK_DECODER_REGISTRY_TOKEN,
Expand Down Expand Up @@ -58,14 +59,25 @@ export class WebhookService implements IWebhookService {
connectionId: string,
rawBody: Buffer,
headers: Record<string, string>
): Promise<void> {
): Promise<Record<string, unknown> | void> {
// Resolve the provider's decoder (ADR-021); fall back to the host's
// OL-HMAC + WebhookRequestDto default for OL-module providers.
const decoder = this.decoderRegistry.get(provider) ?? this.defaultDecoder;
const decoder: InboundWebhookDecoderPort =
this.decoderRegistry.get(provider) ?? this.defaultDecoder;

// Connection gate (provider-agnostic): exists, active, platformType matches.
await this.authService.assertConnectionUsable(provider, connectionId);

// Subscription-verification handshake (e.g. Infakt's `verification_code`
// echo) — runs BEFORE signature verification: the ping precedes any
// signed traffic. Short-circuits with the exact body to echo; no
// verify/dedup/publish for a handshake request.
const handshakeBody = decoder.detectHandshake?.(rawBody, headers);
if (handshakeBody) {
this.logger.log(`Webhook handshake detected: provider=${provider}, connectionId=${connectionId}`);
return handshakeBody;
}

// Verify the signature via the decoder (host supplies the per-connection
// secret). Then replay-check the decoder-normalized timestamp. Order is
// verify → replay because a third-party timestamp is only trusted once the
Expand Down
20 changes: 18 additions & 2 deletions apps/api/src/webhooks/http/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import {
Param,
Headers,
Req,
Res,
HttpCode,
HttpStatus,
BadRequestException,
UnauthorizedException,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiHeader } from '@nestjs/swagger';
import { Public } from '../../auth/decorators/public.decorator';
import { WebhookService } from '../application/services/webhook.service';
Expand Down Expand Up @@ -57,6 +59,7 @@ export class WebhookController {
'Provider-specific webhook signature. OL-module providers send `sha256=<hex>`; third-party providers use their own scheme + header (e.g. InPost `x-inpost-signature`, base64 HMAC). Resolved per-provider by the registered decoder.',
required: false,
})
@ApiResponse({ status: 200, description: 'Subscription-verification handshake echoed back' })
@ApiResponse({ status: 202, description: 'Webhook accepted and queued for processing' })
@ApiResponse({ status: 400, description: 'Invalid request payload or malformed data' })
@ApiResponse({ status: 401, description: 'Invalid signature or timestamp out of window' })
Expand All @@ -67,7 +70,12 @@ export class WebhookController {
@Param('connectionId') connectionId: string,
@Headers() headers: Record<string, string>,
@Req() req: RequestWithRawBody,
): Promise<void> {
// Only usage of the raw Response object in this controller — needed to
// override the route's default 202 with 200 on the handshake-echo path
// (see the res.status(HttpStatus.OK) call below). Passthrough mode still
// lets Nest serialize the returned body as normal.
@Res({ passthrough: true }) res: Response,
): Promise<Record<string, unknown> | void> {
// No `@Body() WebhookRequestDto` — the body shape is the provider's, not
// OL's (ADR-021). The per-provider decoder (resolved in WebhookService)
// verifies + parses the raw bytes; the host OL-module default decoder still
Expand Down Expand Up @@ -100,7 +108,15 @@ export class WebhookController {
}

try {
await this.webhookService.processWebhook(provider, connectionId, rawBody, headers);
const result = await this.webhookService.processWebhook(provider, connectionId, rawBody, headers);
if (result !== undefined) {
// Handshake echo — verified live against Infakt's own "Zweryfikuj"
// button (2026-07-01): it reports "could not verify" on our default
// 202, but accepts 200. Every other outcome (route/ignore) keeps the
// route's default 202 via @HttpCode above.
res.status(HttpStatus.OK);
}
return result;
} catch (error) {
// Map domain exceptions to HTTP exceptions
if (error instanceof WebhookDecodeException) {
Expand Down
8 changes: 8 additions & 0 deletions apps/api/test/jest-integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ module.exports = {
__dirname,
'../../../libs/integrations/dpd-polska/src/$1',
),
'^@openlinker/integrations-infakt$': path.resolve(
__dirname,
'../../../libs/integrations/infakt/src/index.ts',
),
'^@openlinker/integrations-infakt/(.*)$': path.resolve(
__dirname,
'../../../libs/integrations/infakt/src/$1',
),
'^@openlinker/test-kit$': path.resolve(__dirname, '../../../libs/test-kit/src/index.ts'),
'^@openlinker/test-kit/(.*)$': path.resolve(__dirname, '../../../libs/test-kit/src/$1'),
},
Expand Down
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@openlinker/integrations-allegro": "workspace:*",
"@openlinker/integrations-dpd-polska": "workspace:*",
"@openlinker/integrations-erli": "workspace:*",
"@openlinker/integrations-infakt": "workspace:*",
"@openlinker/integrations-ksef": "workspace:*",
"@openlinker/integrations-inpost": "workspace:*",
"@openlinker/integrations-prestashop": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions apps/worker/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { DpdIntegrationModule } from '@openlinker/integrations-dpd-polska';
import { ErliIntegrationModule } from '@openlinker/integrations-erli';
import { KsefIntegrationModule } from '@openlinker/integrations-ksef';
import { SubiektIntegrationModule } from '@openlinker/integrations-subiekt';
import { InfaktIntegrationModule } from '@openlinker/integrations-infakt';

export const workerPlugins: PluginEntry[] = [
PrestashopIntegrationModule,
Expand All @@ -53,4 +54,7 @@ export const workerPlugins: PluginEntry[] = [
// #753: resolve the Subiekt 'Invoicing' capability when issuance is driven
// from the worker (mirrors WooCommerce/InPost dual registration).
SubiektIntegrationModule,
// #1281: resolve the Infakt 'Invoicing' capability when issuance/reconcile
// jobs run from the worker (mirrors Subiekt/KSeF dual registration).
InfaktIntegrationModule,
];
8 changes: 8 additions & 0 deletions apps/worker/test/jest-integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,13 @@ module.exports = {
__dirname,
'../../../libs/integrations/dpd-polska/src/$1',
),
'^@openlinker/integrations-infakt$': path.resolve(
__dirname,
'../../../libs/integrations/infakt/src/index.ts',
),
'^@openlinker/integrations-infakt/(.*)$': path.resolve(
__dirname,
'../../../libs/integrations/infakt/src/$1',
),
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Implementation Plan — Infakt plugin registration + webhook routing (#1281)

## 1. Goal / non-goals

Wire the already-hardened Infakt plugin (#1280, PR #1292, not yet merged — this
branch is stacked on `1280-infakt-plugin-hardening-tests`) into the running API,
and connect Infakt's inbound webhooks (KSeF clearance notifications relayed by
Infakt) to OL's webhook ingestion pipeline.

Non-goals: `WebhookProvisioningPort` (Infakt webhook setup is UI-only, per
issue scope), FE plugin (#1282), docs/ADR (#1283).

## 2. Layer classification

Integration (`libs/integrations/infakt`) + thin core extension
(`libs/core/src/integrations`, `libs/core/src/sync` — additive domain value +
routing case) + host wiring (`apps/api`).

## 3. Key finding from research

- `libs/integrations/infakt` only exists on `1280-infakt-plugin-hardening-tests`
(unmerged). This branch is based on it per user instruction; PR opens
against `main` as **draft** and will show #1280's diff until #1292 merges.
- Registering a `WebhookEventTranslatorPort` alone is not enough: Infakt's
webhook is **not** OL-enveloped and uses its own HMAC scheme + a
verification handshake (`{"verification_code":...}` echo) — this is exactly
the third-party-native case ADR-021's `InboundWebhookDecoderPort` exists for
(see InPost as the precedent).
- The existing `InboundEventDomainValues` closed union
(`order | inventory | product | shipment`) has no `invoicing` value, and
`InboundRoutingPolicyService` has no case for it. Reconciling clearance
status already has a job: `invoicing.regulatoryStatus.reconcile`
(page-scan over non-terminal invoices, no target-id payload) — a webhook
is a **trigger, not the source of truth** (matches the InPost/webhook
philosophy already documented in `architecture-overview.md`), so routing
the Infakt event to this existing reconcile job (rather than inventing a
by-id job) is the smallest correct change.
- No existing mechanism lets a decoder produce a custom response body
(needed for the verification-code echo). Adding one optional method to
`InboundWebhookDecoderPort` is the minimal extension; it's additive so
`DefaultWebhookDecoder` / InPost's decoder need no change (default to
no-op via `?.()`).

## 4. Steps

1. **Dependency wiring**
- `apps/api/package.json`, `apps/worker/package.json`: add
`"@openlinker/integrations-infakt": "workspace:*"`.
- `apps/api/src/plugins.ts`: import + append `InfaktIntegrationModule`.
- `apps/api/test/jest-integration.cjs`: add the two moduleNameMapper
entries (mirrors the ksef block).

2. **Handshake extension (core, additive)**
- `libs/core/src/integrations/domain/ports/inbound-webhook-decoder.port.ts`:
add optional `detectHandshake?(rawBody: Buffer, headers: Record<string,
string>): Record<string, unknown> | null` — runs before signature
verification (a subscription-verification ping predates any real
traffic and isn't itself required to be signed by Infakt's documented
flow).
- `apps/api/src/webhooks/application/services/webhook.service.ts`: after
the connection gate, call `decoder.detectHandshake?.(rawBody, headers)`;
if it returns non-null, return it immediately (no verify/dedup/publish).
- `IWebhookService.processWebhook` return type: `Promise<Record<string,
unknown> | void>`.
- `WebhookController.receiveWebhook`: return type updated to match;
NestJS serializes the returned object as the JSON body under the
existing `@HttpCode(ACCEPTED)`.

3. **`invoicing` inbound domain (core, additive)**
- `libs/core/src/integrations/domain/types/canonical-inbound-event.types.ts`:
add `'invoicing'` to `InboundEventDomainValues`.
- `libs/core/src/sync/application/services/inbound-routing-policy.service.ts`:
add a `case 'invoicing':` → `{ jobType: 'invoicing.regulatoryStatus.reconcile',
requiredCapability: 'Invoicing', payload: { schemaVersion: 1, limit: 50 }
satisfies RegulatoryStatusReconcilePayloadV1 }`.

4. **Infakt decoder + translator** (`libs/integrations/infakt/src/infrastructure/adapters/`)
- `infakt-inbound-webhook-decoder.adapter.ts` —
`InfaktInboundWebhookDecoderAdapter implements InboundWebhookDecoderPort`:
wraps the existing `InfaktWebhookTranslator` (verify via HMAC-SHA256 hex
over raw body per its `verifySignature`; `detectHandshake` via its
`getVerificationEcho`; `extractEnvelope` via its `parse`, mapping to
`{eventId: event.uuid, eventType: event.name, occurredAt:
event.created_at, objectType: 'invoice', externalId:
resource.invoice_uuid ?? event.uuid, payload: resource}`).
- `infakt-webhook-event-translator.adapter.ts` —
`InfaktWebhookEventTranslatorAdapter implements WebhookEventTranslatorPort`:
`translate` returns `{domain: 'invoicing', externalId, eventType,
occurredAt, payload}` for `objectType === 'invoice'`, else `null`.
- Register both in `infakt-plugin.ts`'s `register(host)`:
`host.inboundWebhookDecoderRegistry.register(platformType, decoder)`,
`host.webhookEventTranslatorRegistry.register(adapterKey, translator)`.

5. **Tests**
- Unit specs for the two new adapters (happy path + malformed/unsigned
rejection + handshake echo).
- Extend `inbound-routing-policy.service.spec.ts` with the new
`'invoicing'` case.
- Extend `webhook.service.spec.ts` with a handshake-short-circuit case.

6. **Quality gate**: `pnpm lint && pnpm type-check && pnpm test`
(scoped to affected packages given resource constraints; full run before
PR).

## 5. Risks / open questions

- Exact HTTP status Infakt expects for the verification echo is unconfirmed
(POC never shipped a controller for it) — using the existing `202` path.
Flagged in the PR description as a manual-verification item, consistent
with #1292's own test-plan checkboxes.
- `invoicing.regulatoryStatus.reconcile`'s `limit: 50` on webhook-triggered
runs is a nudge, not a guarantee the specific invoice is within that page —
acceptable because the scheduled reconcile already drains the full
frontier; the webhook only shortens latency.
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,15 @@ export interface InboundWebhookDecoderPort {
* (malformed). Must be total — never throw unbounded.
*/
extractEnvelope(rawBody: Buffer, headers: Record<string, string>): DecodeResult;

/**
* Detect a provider-native subscription-verification handshake (e.g.
* Infakt's `{"verification_code": "..."}` ping the endpoint must echo back
* to activate the webhook) and return the exact JSON body to echo, or
* `null` if this isn't a handshake request. Runs BEFORE `verify` — a
* handshake ping precedes any signed traffic and predates a rotated
* secret being meaningful. Optional: providers without a handshake step
* omit it, and the host skips straight to `verify`.
*/
detectHandshake?(rawBody: Buffer, headers: Record<string, string>): Record<string, unknown> | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
* @module libs/core/src/integrations/domain/types
*/

export const InboundEventDomainValues = ['order', 'inventory', 'product', 'shipment'] as const;
export const InboundEventDomainValues = [
'order',
'inventory',
'product',
'shipment',
'invoicing',
] as const;

export type InboundEventDomain = (typeof InboundEventDomainValues)[number];

Expand Down
Loading
Loading