diff --git a/apps/api/src/invoicing/http/invoicing.controller.ts b/apps/api/src/invoicing/http/invoicing.controller.ts
index e0c412b4e..882dd5dd2 100644
--- a/apps/api/src/invoicing/http/invoicing.controller.ts
+++ b/apps/api/src/invoicing/http/invoicing.controller.ts
@@ -68,6 +68,7 @@ import {
IInvoiceService,
toIssueInvoiceCommand,
InvalidBuyerProfileError,
+ InvalidInvoiceLineError,
UnsupportedPriceTreatmentError,
DuplicateInvoiceRecordException,
InvoiceRecordNotFoundException,
@@ -1137,6 +1138,7 @@ export class InvoicingController {
}
if (
error instanceof InvalidBuyerProfileError ||
+ error instanceof InvalidInvoiceLineError ||
error instanceof UnsupportedPriceTreatmentError
) {
return new BadRequestException(error.message);
diff --git a/apps/web/src/plugins/ksef/components/ksef-setup-form.test.tsx b/apps/web/src/plugins/ksef/components/ksef-setup-form.test.tsx
index 212eb0acf..430da86a1 100644
--- a/apps/web/src/plugins/ksef/components/ksef-setup-form.test.tsx
+++ b/apps/web/src/plugins/ksef/components/ksef-setup-form.test.tsx
@@ -147,4 +147,40 @@ describe('KsefSetupForm', () => {
},
});
});
+
+ it('prefills the default line unit with "szt." and maps it to config.invoiceDefaults (#1525)', async () => {
+ const create = vi.fn().mockResolvedValue({ id: 'conn-ksef', name: 'KSeF main' });
+ const apiClient = createMockApiClient({ connections: { create } });
+ renderWithProviders(, { apiClient });
+
+ const unit = screen.getByLabelText('Default line unit');
+ expect(unit).toHaveValue('szt.');
+
+ fireEvent.change(screen.getByLabelText('Connection name'), { target: { value: 'KSeF main' } });
+ fireEvent.change(screen.getByLabelText('Authentication secret'), {
+ target: { value: 'tok_secret_value' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Connect KSeF' }));
+
+ await waitFor(() => expect(create).toHaveBeenCalled());
+ const payload = create.mock.calls[0][0] as { config: Record };
+ expect(payload.config.invoiceDefaults).toEqual({ lineUnit: 'szt.' });
+ });
+
+ it('clearing the default line unit omits config.invoiceDefaults entirely (#1525)', async () => {
+ const create = vi.fn().mockResolvedValue({ id: 'conn-ksef', name: 'KSeF main' });
+ const apiClient = createMockApiClient({ connections: { create } });
+ renderWithProviders(, { apiClient });
+
+ fireEvent.change(screen.getByLabelText('Connection name'), { target: { value: 'KSeF main' } });
+ fireEvent.change(screen.getByLabelText('Default line unit'), { target: { value: '' } });
+ fireEvent.change(screen.getByLabelText('Authentication secret'), {
+ target: { value: 'tok_secret_value' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Connect KSeF' }));
+
+ await waitFor(() => expect(create).toHaveBeenCalled());
+ const payload = create.mock.calls[0][0] as { config: Record };
+ expect('invoiceDefaults' in payload.config).toBe(false);
+ });
});
diff --git a/apps/web/src/plugins/ksef/components/ksef-setup-form.tsx b/apps/web/src/plugins/ksef/components/ksef-setup-form.tsx
index 28c52d631..5f41358b1 100644
--- a/apps/web/src/plugins/ksef/components/ksef-setup-form.tsx
+++ b/apps/web/src/plugins/ksef/components/ksef-setup-form.tsx
@@ -23,6 +23,7 @@ import { useCreateConnectionMutation } from '../../../features/connections';
import {
KSEF_AUTH_TYPE_VALUES,
KSEF_ENVIRONMENT_VALUES,
+ KSEF_LINE_UNIT_SUGGESTIONS,
KSEF_SETUP_DEFAULT_VALUES,
ksefSetupSchema,
toCreateConnectionInput,
@@ -254,6 +255,27 @@ export function KsefSetupForm(): ReactElement {
/>
+
+
+
+ {/* Outside FormField (it requires a single child); linked via list=. */}
+
+
0) {
config.contextIdentifier = values.contextIdentifier;
}
+ // Cleared field = no `invoiceDefaults` at all (P_8A not emitted), #1525.
+ if (values.invoiceDefaultLineUnit && values.invoiceDefaultLineUnit.length > 0) {
+ config.invoiceDefaults = { lineUnit: values.invoiceDefaultLineUnit };
+ }
return {
name: values.name,
diff --git a/apps/web/src/plugins/ksef/components/ksef-structured-section.tsx b/apps/web/src/plugins/ksef/components/ksef-structured-section.tsx
index 489df876c..9ba754df7 100644
--- a/apps/web/src/plugins/ksef/components/ksef-structured-section.tsx
+++ b/apps/web/src/plugins/ksef/components/ksef-structured-section.tsx
@@ -26,7 +26,11 @@ import { FormField } from '../../../shared/ui/form-field';
import { Input } from '../../../shared/ui/input';
import { Select } from '../../../shared/ui/select';
import type { StructuredConfigSectionProps } from '../../../shared/plugins';
-import { KSEF_ENVIRONMENT_VALUES, KSEF_FORMA_PLATNOSCI_VALUES } from './ksef-setup.schema';
+import {
+ KSEF_ENVIRONMENT_VALUES,
+ KSEF_FORMA_PLATNOSCI_VALUES,
+ KSEF_LINE_UNIT_SUGGESTIONS,
+} from './ksef-setup.schema';
const ENVIRONMENT_LABELS: Record<(typeof KSEF_ENVIRONMENT_VALUES)[number], string> = {
test: 'Test (sandbox)',
@@ -304,6 +308,28 @@ export function KsefStructuredSection({
invalid={Boolean(form.formState.errors.paymentSkontoAmount)}
/>
+
+ syncStructuredToJson('invoiceDefaultLineUnit', event.target.value)}
+ list="ksef-edit-line-unit-suggestions"
+ placeholder="szt."
+ autoComplete="off"
+ disabled={!configIsParseable}
+ invalid={Boolean(form.formState.errors.invoiceDefaultLineUnit)}
+ />
+
+ {/* Outside FormField (it requires a single child); linked via list=. */}
+
>
);
}
diff --git a/apps/web/src/plugins/ksef/ksef-connection-config.test.ts b/apps/web/src/plugins/ksef/ksef-connection-config.test.ts
index b95ea2e1c..9a991cf90 100644
--- a/apps/web/src/plugins/ksef/ksef-connection-config.test.ts
+++ b/apps/web/src/plugins/ksef/ksef-connection-config.test.ts
@@ -420,6 +420,7 @@ describe('ksefConnectionConfig.readConfigToForm — hydration', () => {
paymentTermDays: '14',
paymentSkontoConditions: 'text',
paymentSkontoAmount: '2%',
+ invoiceDefaultLineUnit: '',
});
});
@@ -433,5 +434,68 @@ describe('ksefConnectionConfig.readConfigToForm — hydration', () => {
expect(values.ksefEnvironment).toBe('');
expect(values.sellerNip).toBe('');
expect(values.paymentFormaPlatnosci).toBe('');
+ expect(values.invoiceDefaultLineUnit).toBe('');
+ });
+
+ it('hydrates the default line unit from config.invoiceDefaults.lineUnit (#1525)', () => {
+ const values = ksefConnectionConfig.readConfigToForm({
+ env: 'test',
+ invoiceDefaults: { lineUnit: 'szt.' },
+ });
+ expect(values.invoiceDefaultLineUnit).toBe('szt.');
+ });
+});
+
+describe('mergeStructuredIntoConfig - KSeF invoiceDefaults.lineUnit (#1525)', () => {
+ it('assembles the nested config.invoiceDefaults shape resolveDefaultLineUnit reads', () => {
+ const result = mergeStructuredIntoConfig(
+ { env: 'test' },
+ { invoiceDefaultLineUnit: 'kg' },
+ ksefConnectionConfig,
+ );
+ expect(result.invoiceDefaults).toEqual({ lineUnit: 'kg' });
+ });
+
+ it('trims the value before persisting', () => {
+ const result = mergeStructuredIntoConfig(
+ { env: 'test' },
+ { invoiceDefaultLineUnit: ' szt. ' },
+ ksefConnectionConfig,
+ );
+ expect(result.invoiceDefaults).toEqual({ lineUnit: 'szt.' });
+ });
+
+ it('clearing the field drops the leaf AND the hollow invoiceDefaults object (stops P_8A emission)', () => {
+ const result = mergeStructuredIntoConfig(
+ { env: 'test', invoiceDefaults: { lineUnit: 'szt.' } },
+ { invoiceDefaultLineUnit: '' },
+ ksefConnectionConfig,
+ );
+ expect('invoiceDefaults' in result).toBe(false);
+ });
+
+ it('does not touch config.invoiceDefaults when the field is not on the patch', () => {
+ const base = { env: 'test', invoiceDefaults: { lineUnit: 'kpl.' } };
+ const result = mergeStructuredIntoConfig(base, { ksefEnvironment: 'demo' }, ksefConnectionConfig);
+ expect(result.invoiceDefaults).toEqual({ lineUnit: 'kpl.' });
+ });
+});
+
+describe('composed edit schema - KSeF default line unit length (#1525)', () => {
+ const base = { name: 'ksef', status: 'active', configText: '{}', credentialsText: '' };
+
+ it('accepts a unit within the 20-char cap', () => {
+ const parsed = ksefSchema.safeParse({ ...base, invoiceDefaultLineUnit: 'szt.' });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('allows an empty unit (clearable field)', () => {
+ const parsed = ksefSchema.safeParse({ ...base, invoiceDefaultLineUnit: '' });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects a unit longer than 20 characters after trim', () => {
+ const parsed = ksefSchema.safeParse({ ...base, invoiceDefaultLineUnit: 'x'.repeat(21) });
+ expect(parsed.success).toBe(false);
});
});
diff --git a/apps/web/src/plugins/ksef/ksef-connection-config.ts b/apps/web/src/plugins/ksef/ksef-connection-config.ts
index b90e06fde..97b09907e 100644
--- a/apps/web/src/plugins/ksef/ksef-connection-config.ts
+++ b/apps/web/src/plugins/ksef/ksef-connection-config.ts
@@ -68,6 +68,11 @@ declare module '../../shared/plugins/plugin.types' {
paymentTermDays?: string;
paymentSkontoConditions?: string;
paymentSkontoAmount?: string;
+ /**
+ * KSeF default line unit (#1525) - nested `config.invoiceDefaults.lineUnit`,
+ * emitted as `FaWiersz/P_8A`; empty = the unit element is not emitted.
+ */
+ invoiceDefaultLineUnit?: string;
}
}
@@ -154,6 +159,8 @@ const ksefSchemaShape: ConnectionConfigContribution['schemaShape'] = {
.optional(),
paymentSkontoConditions: z.union([z.string().trim().max(512), z.literal('')]).optional(),
paymentSkontoAmount: z.union([z.string().trim().max(64), z.literal('')]).optional(),
+ // Mirrors the BE shape validator's 20-char (after trim) cap (#1525).
+ invoiceDefaultLineUnit: z.union([z.string().trim().max(20), z.literal('')]).optional(),
};
function ksefSuperRefine(values: Record, ctx: z.RefinementCtx): void {
@@ -288,6 +295,23 @@ function readKsefPayment(config: Record): {
};
}
+/**
+ * Read the KSeF default line unit out of `config.invoiceDefaults.lineUnit`
+ * (#1525) for form hydration. An absent/wrong-typed leaf reads as ''.
+ */
+function readKsefInvoiceDefaults(config: Record): {
+ invoiceDefaultLineUnit: string;
+} {
+ const invoiceDefaults =
+ typeof config.invoiceDefaults === 'object' && config.invoiceDefaults !== null
+ ? (config.invoiceDefaults as Record)
+ : {};
+ return {
+ invoiceDefaultLineUnit:
+ typeof invoiceDefaults.lineUnit === 'string' ? invoiceDefaults.lineUnit : '',
+ };
+}
+
/** Rebuild the typed seller patch slice from an untyped structured patch. */
function toSellerPatch(patch: Record): KsefSellerProfileInput {
return {
@@ -345,6 +369,39 @@ function applyKsefConfig(
}
next = applyKsefSellerToConfig(next, toSellerPatch(patch));
next = applyKsefPaymentToConfig(next, toPaymentPatch(patch));
+ next = applyKsefInvoiceDefaultsToConfig(next, patch);
+ return next;
+}
+
+/**
+ * Merge the default-line-unit leaf into `config.invoiceDefaults` (#1525).
+ * Delete-on-empty at both levels: an emptied `lineUnit` leaf is removed, and a
+ * hollow `invoiceDefaults` object is dropped so P_8A emission stops entirely.
+ */
+function applyKsefInvoiceDefaultsToConfig(
+ config: Record,
+ patch: Record,
+): Record {
+ const lineUnit = readOptionalConfigString(patch, 'invoiceDefaultLineUnit');
+ if (lineUnit === undefined) {
+ return config;
+ }
+ const next: Record = { ...config };
+ const invoiceDefaults: Record =
+ typeof next.invoiceDefaults === 'object' && next.invoiceDefaults !== null
+ ? { ...(next.invoiceDefaults as Record) }
+ : {};
+ const normalized = lineUnit.trim();
+ if (normalized.length === 0) {
+ delete invoiceDefaults.lineUnit;
+ } else {
+ invoiceDefaults.lineUnit = normalized;
+ }
+ if (Object.keys(invoiceDefaults).length === 0) {
+ delete next.invoiceDefaults;
+ } else {
+ next.invoiceDefaults = invoiceDefaults;
+ }
return next;
}
@@ -355,6 +412,7 @@ export const ksefConnectionConfig: ConnectionConfigContribution = {
ksefEnvironment: readKsefEnvironment(config),
...readKsefSeller(config),
...readKsefPayment(config),
+ ...readKsefInvoiceDefaults(config),
contextIdentifier: readConfigString(config, 'contextIdentifier'),
}),
applyToConfig: applyKsefConfig,
diff --git a/apps/worker/src/sync/handlers/__tests__/invoicing-issue.handler.spec.ts b/apps/worker/src/sync/handlers/__tests__/invoicing-issue.handler.spec.ts
index 5ce6ae5d7..50bb5dd4b 100644
--- a/apps/worker/src/sync/handlers/__tests__/invoicing-issue.handler.spec.ts
+++ b/apps/worker/src/sync/handlers/__tests__/invoicing-issue.handler.spec.ts
@@ -98,6 +98,17 @@ describe('InvoicingIssueHandler', () => {
await handler.execute(makeJob(makePayload({ idempotencyKey: 'invoice:c:o' })));
expect(invoiceService.issueInvoice.mock.calls[0][0].idempotencyKey).toBe('invoice:c:o');
});
+
+ it('command saleDate is restored from payload.saleDate (#1525)', async () => {
+ await handler.execute(makeJob(makePayload({ saleDate: '2026-06-19' })));
+ expect(invoiceService.issueInvoice.mock.calls[0][0].saleDate).toBe('2026-06-19');
+ });
+
+ it('command omits saleDate when the payload carries none', async () => {
+ await handler.execute(makeJob(makePayload()));
+ const cmd = invoiceService.issueInvoice.mock.calls[0][0];
+ expect('saleDate' in cmd).toBe(false);
+ });
});
describe('deep payload validation ⇒ business_failure (F5)', () => {
@@ -122,6 +133,8 @@ describe('InvoicingIssueHandler', () => {
buyer: { ...makePayload().buyer, taxId: { scheme: 'pl-nip', value: '' } },
})],
['missing connectionId', makePayload({ connectionId: '' })],
+ ['present but empty saleDate', makePayload({ saleDate: '' })],
+ ['present but non-string saleDate', makePayload({ saleDate: 5 as unknown as string })],
];
it.each(cases)('%s ⇒ business_failure (no issueInvoice call)', async (_label, payload) => {
diff --git a/apps/worker/src/sync/handlers/invoicing-issue.handler.ts b/apps/worker/src/sync/handlers/invoicing-issue.handler.ts
index 2b83a86f9..178cff340 100644
--- a/apps/worker/src/sync/handlers/invoicing-issue.handler.ts
+++ b/apps/worker/src/sync/handlers/invoicing-issue.handler.ts
@@ -124,6 +124,8 @@ export class InvoicingIssueHandler implements SyncJobHandler {
if (!isNonEmptyString(p.orderId)) return fail('orderId');
if (!isNonEmptyString(p.idempotencyKey)) return fail('idempotencyKey');
if (!isNonEmptyString(p.currency)) return fail('currency');
+ // Optional additive field (#1525): validated only when present.
+ if (p.saleDate !== undefined && !isNonEmptyString(p.saleDate)) return fail('saleDate');
if (!Array.isArray(p.lines) || p.lines.length < 1 || p.lines.length > MAX_INVOICE_LINES) {
return fail('lines');
@@ -184,6 +186,10 @@ export class InvoicingIssueHandler implements SyncJobHandler {
if (payload.documentType !== undefined) {
command.documentType = payload.documentType;
}
+ // #1525: restore the sale date so the auto-issue path emits it (P_6 on KSeF).
+ if (payload.saleDate !== undefined) {
+ command.saleDate = payload.saleDate;
+ }
return command;
}
diff --git a/libs/core/src/invoicing/application/mappers/errors/invalid-invoice-line.error.ts b/libs/core/src/invoicing/application/mappers/errors/invalid-invoice-line.error.ts
new file mode 100644
index 000000000..a497d3220
--- /dev/null
+++ b/libs/core/src/invoicing/application/mappers/errors/invalid-invoice-line.error.ts
@@ -0,0 +1,19 @@
+/**
+ * InvalidInvoiceLineError
+ *
+ * Neutral mapping error thrown by the Order -> IssueInvoiceCommand composer when
+ * an order item cannot form a valid invoice line - today: a quantity that is not
+ * a positive finite number (#1525). A zero/negative quantity typically comes from
+ * a malformed order snapshot (the rehydrator defaults it to 0); passing it through
+ * would divide unit-price derivations (KSeF's P_9A) to NaN downstream. The mapper
+ * fails loud instead. Messages cite ONLY `order.id`. No country/document-type
+ * vocabulary.
+ *
+ * @module libs/core/src/invoicing/application/mappers/errors
+ */
+export class InvalidInvoiceLineError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'InvalidInvoiceLineError';
+ }
+}
diff --git a/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.spec.ts b/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.spec.ts
index 6fee7e070..986b48772 100644
--- a/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.spec.ts
+++ b/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.spec.ts
@@ -10,6 +10,7 @@
import type { Address, Order, OrderItem } from '@openlinker/core/orders';
import { InvalidBuyerProfileError } from './errors/invalid-buyer-profile.error';
+import { InvalidInvoiceLineError } from './errors/invalid-invoice-line.error';
import { UnsupportedPriceTreatmentError } from './errors/unsupported-price-treatment.error';
import { toIssueInvoiceCommand } from './order-to-issue-invoice-command.mapper';
@@ -91,6 +92,44 @@ describe('toIssueInvoiceCommand', () => {
expect(cmd.lines[2].name).toBe('PID-5');
});
+ it('should fill saleDate from placedAt (ISO YYYY-MM-DD) when the order carries one', () => {
+ const cmd = toIssueInvoiceCommand({
+ order: makeOrder({ placedAt: new Date('2026-06-20T14:30:00.000Z') }),
+ connectionId: 'conn-1',
+ });
+
+ expect(cmd.saleDate).toBe('2026-06-20');
+ });
+
+ it.each([
+ ['zero', 0],
+ ['negative', -2],
+ ['NaN', Number.NaN],
+ ])('should throw InvalidInvoiceLineError for a %s item quantity (#1525 review)', (_label, quantity) => {
+ const order = makeOrder({ items: [makeItem({ quantity })] });
+ expect(() => toIssueInvoiceCommand({ order, connectionId: 'conn-1' })).toThrow(
+ InvalidInvoiceLineError,
+ );
+ });
+
+ it('InvalidInvoiceLineError is PII-clean: cites only the order id', () => {
+ const order = makeOrder({ items: [makeItem({ quantity: 0, name: 'SECRET_ITEM' })] });
+ try {
+ toIssueInvoiceCommand({ order, connectionId: 'conn-1' });
+ fail('expected InvalidInvoiceLineError');
+ } catch (error) {
+ expect((error as Error).message).toContain('order-1');
+ expect((error as Error).message).not.toContain('SECRET_ITEM');
+ }
+ });
+
+ it('should leave saleDate undefined when placedAt is absent (never substitute createdAt)', () => {
+ const cmd = toIssueInvoiceCommand({ order: makeOrder(), connectionId: 'conn-1' });
+
+ // createdAt is set on the fixture; it must NOT leak into saleDate.
+ expect(cmd.saleDate).toBeUndefined();
+ });
+
it('documentType pass-through: undefined stays undefined; supplied value verbatim; NO derivation', () => {
const noDoc = toIssueInvoiceCommand({ order: makeOrder(), connectionId: 'conn-1' });
expect(noDoc.documentType).toBeUndefined();
diff --git a/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.ts b/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.ts
index af31bf772..174c8e786 100644
--- a/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.ts
+++ b/libs/core/src/invoicing/application/mappers/order-to-issue-invoice-command.mapper.ts
@@ -20,6 +20,7 @@ import type {
TaxIdentifier,
} from '../../domain/types/invoicing.types';
import { InvalidBuyerProfileError } from './errors/invalid-buyer-profile.error';
+import { InvalidInvoiceLineError } from './errors/invalid-invoice-line.error';
import { UnsupportedPriceTreatmentError } from './errors/unsupported-price-treatment.error';
/** Inputs to {@link toIssueInvoiceCommand}. */
@@ -35,8 +36,10 @@ export interface OrderToIssueInvoiceCommandInput {
/**
* Compose an `IssueInvoiceCommand` from an `Order`. Throws `InvalidBuyerProfileError`
- * when no address/buyer-name can be derived, and `UnsupportedPriceTreatmentError`
- * when the order is net-priced (`taxTreatment === 'exclusive'`).
+ * when no address/buyer-name can be derived, `UnsupportedPriceTreatmentError`
+ * when the order is net-priced (`taxTreatment === 'exclusive'`), and
+ * `InvalidInvoiceLineError` when an item's quantity is not a positive finite
+ * number (#1525).
*/
export function toIssueInvoiceCommand(
input: OrderToIssueInvoiceCommandInput,
@@ -53,7 +56,7 @@ export function toIssueInvoiceCommand(
}
const buyer = buildBuyerProfile(order, buyerTaxId ?? null);
- const lines = order.items.map(toInvoiceLine);
+ const lines = order.items.map((item) => toInvoiceLine(item, order.id));
const command: IssueInvoiceCommand = {
connectionId,
@@ -63,6 +66,12 @@ export function toIssueInvoiceCommand(
lines,
};
+ // saleDate comes ONLY from the marketplace placement timestamp. When
+ // `placedAt` is absent the field stays undefined - `createdAt` is OL's
+ // ingestion clock, not the sale date, and must never substitute (#1525).
+ if (order.placedAt !== undefined) {
+ command.saleDate = toIsoDate(order.placedAt);
+ }
// documentType is PASS-THROUGH ONLY. Undefined stays undefined; the adapter
// derives it. No derivation, no faktura/paragon/NIP vocabulary here.
if (documentType !== undefined) {
@@ -121,6 +130,20 @@ function deriveBuyerName(order: Order, address: Address): string {
return person;
}
+/**
+ * Format a timestamp as an ISO 8601 calendar date (`YYYY-MM-DD`).
+ *
+ * UTC-day semantics, ACCEPTED trade-off (#1525 review, mirrors the
+ * `toIsoDateOnly` precedent in `invoicing.controller.ts`): the calendar day is
+ * taken from the instant's UTC representation, so an order placed 00:30 local
+ * time in UTC+2 reports the PREVIOUS UTC day as the sale date. Doing better
+ * would require a seller-timezone setting core does not have; the sale date is
+ * legally tolerant of this off-by-one at day boundaries.
+ */
+function toIsoDate(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
/** Map a core {@link Address} onto the neutral {@link BuyerAddress}. */
function toBuyerAddress(address: Address): BuyerAddress {
return {
@@ -138,8 +161,18 @@ function toBuyerAddress(address: Address): BuyerAddress {
* `sku` then `productId` when the source omitted a label. `taxRate` is left
* empty here — the provider adapter resolves the regime rate; core never names
* a tax rate on the order contract.
+ *
+ * Throws {@link InvalidInvoiceLineError} (PII-clean, cites only `orderId`) when
+ * the quantity is not a positive finite number - a malformed order snapshot
+ * defaults it to 0, and letting that through would corrupt per-unit derivations
+ * downstream (division to NaN, #1525).
*/
-function toInvoiceLine(item: OrderItem): InvoiceLine {
+function toInvoiceLine(item: OrderItem, orderId: string): InvoiceLine {
+ if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
+ throw new InvalidInvoiceLineError(
+ `Order ${orderId} has an item with a non-positive quantity; cannot compose an invoice line`,
+ );
+ }
return {
name: item.name?.trim() || item.sku || item.productId,
quantity: item.quantity,
diff --git a/libs/core/src/invoicing/application/services/auto-issue-trigger.service.spec.ts b/libs/core/src/invoicing/application/services/auto-issue-trigger.service.spec.ts
index ec00a2572..1dce6603a 100644
--- a/libs/core/src/invoicing/application/services/auto-issue-trigger.service.spec.ts
+++ b/libs/core/src/invoicing/application/services/auto-issue-trigger.service.spec.ts
@@ -224,6 +224,23 @@ describe('AutoIssueTriggerService', () => {
expect(buyer.type).toBe('private');
expect(buyer.taxId).toBeNull();
});
+
+ it('payload carries saleDate from order.placedAt (P_6 seam, #1525)', async () => {
+ connectionPort.list.mockResolvedValue([makeConnection('auto-on-paid')]);
+ await service.onOrderTransition(
+ makeOrder({ paymentStatus: 'paid', placedAt: new Date('2026-06-19T14:30:00.000Z') }),
+ 'src-1',
+ );
+ const payload = syncJobs.schedule.mock.calls[0][0].payload as { saleDate?: string };
+ expect(payload.saleDate).toBe('2026-06-19');
+ });
+
+ it('payload omits saleDate entirely when the order has no placedAt', async () => {
+ connectionPort.list.mockResolvedValue([makeConnection('auto-on-paid')]);
+ await service.onOrderTransition(makeOrder({ paymentStatus: 'paid' }), 'src-1');
+ const payload = syncJobs.schedule.mock.calls[0][0].payload;
+ expect('saleDate' in payload).toBe(false);
+ });
});
describe('per-connection isolation + PII-safe catch (F9/D11)', () => {
diff --git a/libs/core/src/invoicing/application/services/auto-issue-trigger.service.ts b/libs/core/src/invoicing/application/services/auto-issue-trigger.service.ts
index 2658c056b..e9233cc44 100644
--- a/libs/core/src/invoicing/application/services/auto-issue-trigger.service.ts
+++ b/libs/core/src/invoicing/application/services/auto-issue-trigger.service.ts
@@ -68,6 +68,7 @@ const INVOICING_CAPABILITY = 'Invoicing';
*/
const PII_SAFE_ERROR_NAMES: ReadonlySet = new Set([
'InvalidBuyerProfileError',
+ 'InvalidInvoiceLineError',
'UnsupportedPriceTreatmentError',
'BatchedTriggerNotImplementedError',
]);
@@ -258,6 +259,11 @@ export class AutoIssueTriggerService implements IAutoIssueTriggerService {
if (command.documentType !== undefined) {
payload.documentType = command.documentType;
}
+ // #1525: without this the field-by-field flatten silently drops the sale
+ // date and the auto-issued document loses its P_6 counterpart.
+ if (command.saleDate !== undefined) {
+ payload.saleDate = command.saleDate;
+ }
if (sourceEventId !== undefined) {
payload.sourceEventId = sourceEventId;
}
diff --git a/libs/core/src/invoicing/domain/types/invoicing.types.ts b/libs/core/src/invoicing/domain/types/invoicing.types.ts
index a23d8f087..9fcd9b01e 100644
--- a/libs/core/src/invoicing/domain/types/invoicing.types.ts
+++ b/libs/core/src/invoicing/domain/types/invoicing.types.ts
@@ -218,6 +218,14 @@ export interface InvoiceLine {
quantity: number;
unitPriceGross: number;
taxRate: string;
+ /**
+ * Unit of measure for the quantity (free text, e.g. a piece/kg/hour label in
+ * the seller's language). Country-agnostic (#1525): a neutral commercial
+ * concept, not a regime code. Optional - marketplace orders carry no unit,
+ * so the order mapper never sets it; the field is the seam for future
+ * sources that do. Providers without a unit concept ignore it.
+ */
+ unit?: string;
}
/**
@@ -253,7 +261,7 @@ export interface IssuedLineSnapshot {
buyer: IssuedSnapshotBuyer;
/** ISO 4217 currency code, echoed from the issue command. */
currency: string;
- /** Lines exactly as issued (name/quantity/unitPriceGross/taxRate). */
+ /** Lines exactly as issued (name/quantity/unitPriceGross/taxRate/unit). */
lines: InvoiceLine[];
}
@@ -409,6 +417,13 @@ export interface IssueInvoiceCommand {
lines: InvoiceLine[];
/** Neutral document type; well-known values in {@link DocumentTypeValues} (open-world). */
documentType?: string;
+ /**
+ * Date of supply / sale, ISO 8601 calendar `YYYY-MM-DD` (#1525). Filled from
+ * the order's marketplace placement timestamp (`Order.placedAt`) - NEVER from
+ * OL's ingestion clock (`Order.createdAt`). Absent when the source order does
+ * not carry a placement date; adapters omit the corresponding wire field.
+ */
+ saleDate?: string;
/** Correction linkage + reason; present only for a correcting document. */
correction?: CorrectionReference;
idempotencyKey?: string;
diff --git a/libs/core/src/invoicing/index.ts b/libs/core/src/invoicing/index.ts
index 77934173c..495fb255c 100644
--- a/libs/core/src/invoicing/index.ts
+++ b/libs/core/src/invoicing/index.ts
@@ -36,6 +36,7 @@ export * from './domain/exceptions/duplicate-invoice-record.exception';
export * from './domain/exceptions/source-document-immutable.error';
export { BatchedTriggerNotImplementedError } from './domain/exceptions/batched-trigger-not-implemented.error';
export { InvalidBuyerProfileError } from './application/mappers/errors/invalid-buyer-profile.error';
+export { InvalidInvoiceLineError } from './application/mappers/errors/invalid-invoice-line.error';
export { UnsupportedPriceTreatmentError } from './application/mappers/errors/unsupported-price-treatment.error';
export * from './domain/exceptions/unsupported-regulatory-document-kind.error';
export {
diff --git a/libs/core/src/orders/domain/order-from-ready-snapshot.spec.ts b/libs/core/src/orders/domain/order-from-ready-snapshot.spec.ts
index 7a8b680de..01aebe606 100644
--- a/libs/core/src/orders/domain/order-from-ready-snapshot.spec.ts
+++ b/libs/core/src/orders/domain/order-from-ready-snapshot.spec.ts
@@ -90,6 +90,36 @@ describe('orderFromReadySnapshot', () => {
expect(order.updatedAt.toISOString()).toBe('2026-06-21T09:30:00.000Z');
});
+ it('rehydrates placedAt from the snapshot when present (P_6 source, #1525)', () => {
+ const order = orderFromReadySnapshot(
+ makeRecord({ ...READY_SNAPSHOT, placedAt: '2026-06-19T14:30:00.000Z' }),
+ );
+
+ expect(order.placedAt).toBeInstanceOf(Date);
+ expect(order.placedAt?.toISOString()).toBe('2026-06-19T14:30:00.000Z');
+ });
+
+ it('leaves placedAt undefined when the snapshot has none (no fallback substitution)', () => {
+ const order = orderFromReadySnapshot(makeRecord(READY_SNAPSHOT));
+ expect(order.placedAt).toBeUndefined();
+ });
+
+ it('leaves placedAt undefined when the snapshot value is not a parseable date string', () => {
+ const order = orderFromReadySnapshot(
+ makeRecord({ ...READY_SNAPSHOT, placedAt: 'not-a-date' }),
+ );
+ expect(order.placedAt).toBeUndefined();
+ });
+
+ it('rehydrates placedAt from a Date instance too (same string|Date acceptance as createdAt/updatedAt)', () => {
+ const order = orderFromReadySnapshot(
+ makeRecord({ ...READY_SNAPSHOT, placedAt: new Date('2026-06-19T14:30:00.000Z') }),
+ );
+
+ expect(order.placedAt).toBeInstanceOf(Date);
+ expect(order.placedAt?.toISOString()).toBe('2026-06-19T14:30:00.000Z');
+ });
+
it('falls back to shipping address when billing is absent', () => {
const { billingAddress: _omit, ...rest } = READY_SNAPSHOT;
void _omit;
diff --git a/libs/core/src/orders/domain/order-from-ready-snapshot.ts b/libs/core/src/orders/domain/order-from-ready-snapshot.ts
index d55721669..1ba4a2a53 100644
--- a/libs/core/src/orders/domain/order-from-ready-snapshot.ts
+++ b/libs/core/src/orders/domain/order-from-ready-snapshot.ts
@@ -62,6 +62,13 @@ export function orderFromReadySnapshot(record: OrderRecord): Order {
if (typeof snapshot.orderNumber === 'string') {
order.orderNumber = snapshot.orderNumber;
}
+ // placedAt is optional on Order (unlike createdAt/updatedAt) so there is no
+ // record-level fallback to substitute - a snapshot without it stays without
+ // it (invoicing then omits the sale date rather than guessing, #1525).
+ const placedAt = asOptionalDate(snapshot.placedAt);
+ if (placedAt !== undefined) {
+ order.placedAt = placedAt;
+ }
if (typeof snapshot.customerId === 'string') {
order.customerId = snapshot.customerId;
}
@@ -159,16 +166,21 @@ function asString(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback;
}
+/**
+ * Nullable variant of {@link asDate}: rehydrate an ISO date string (the
+ * persisted snapshot shape) or a `Date` instance to a VALID `Date`, or
+ * `undefined` when the value is absent/unparseable/invalid - the no-fallback
+ * semantics optional fields like `placedAt` need (#1525 review round 2).
+ */
+function asOptionalDate(value: unknown): Date | undefined {
+ if (typeof value !== 'string' && !(value instanceof Date)) {
+ return undefined;
+ }
+ const parsed = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
+}
+
/** Rehydrate an ISO date string (the persisted snapshot shape) back to a `Date`. */
function asDate(value: unknown, fallback: Date): Date {
- if (typeof value === 'string') {
- const parsed = new Date(value);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed;
- }
- }
- if (value instanceof Date) {
- return value;
- }
- return fallback;
+ return asOptionalDate(value) ?? fallback;
}
diff --git a/libs/core/src/sync/domain/types/invoicing-job-payloads.types.ts b/libs/core/src/sync/domain/types/invoicing-job-payloads.types.ts
index f12ea78e4..91d0f16c1 100644
--- a/libs/core/src/sync/domain/types/invoicing-job-payloads.types.ts
+++ b/libs/core/src/sync/domain/types/invoicing-job-payloads.types.ts
@@ -54,6 +54,12 @@ export interface InvoicingIssuePayloadV1 {
idempotencyKey: string;
/** Neutral document type; pass-through, adapter derives when absent. */
documentType?: string;
+ /**
+ * Sale date (ISO `YYYY-MM-DD`, #1525) from the order's placement timestamp;
+ * absent when the order carries none. Optional additive field - no
+ * `schemaVersion` bump (a v1 consumer that ignores it stays correct).
+ */
+ saleDate?: string;
/** ISO-4217 currency. */
currency: string;
/** Plain invoice lines (numbers, no class). */
diff --git a/libs/integrations/ksef/src/application/factories/ksef-adapter.factory.ts b/libs/integrations/ksef/src/application/factories/ksef-adapter.factory.ts
index ad6ad1c53..cb15ede68 100644
--- a/libs/integrations/ksef/src/application/factories/ksef-adapter.factory.ts
+++ b/libs/integrations/ksef/src/application/factories/ksef-adapter.factory.ts
@@ -62,6 +62,7 @@ export class KsefAdapterFactory implements IKsefAdapterFactory {
const seller = this.resolveSeller(connection);
const defaultTaxRate = this.resolveDefaultTaxRate(connection);
const payment = this.resolvePayment(connection);
+ const defaultLineUnit = this.resolveDefaultLineUnit(connection);
const { httpClient, publicKeyCache } = createKsefHttpClient({
connectionId: connection.id,
@@ -83,7 +84,7 @@ export class KsefAdapterFactory implements IKsefAdapterFactory {
fa3Builder,
seller,
defaultTaxRate,
- { payment },
+ { payment, defaultLineUnit },
),
};
}
@@ -152,6 +153,21 @@ export class KsefAdapterFactory implements IKsefAdapterFactory {
return config?.seller?.defaultTaxRate?.trim() || DEFAULT_FA3_TAX_RATE;
}
+ /**
+ * Resolve the connection-level default unit of measure (`P_8A`, #1525) from
+ * `config.invoiceDefaults.lineUnit`. Mirrors `resolveDefaultTaxRate`'s
+ * defensive trim; unlike the tax rate there is NO hard default - an
+ * absent/empty value returns `undefined` and unit-less lines omit `P_8A`
+ * entirely (clearing the field stops emission).
+ */
+ private resolveDefaultLineUnit(connection: Connection): string | undefined {
+ const config = connection.config as Partial | undefined;
+ const lineUnit = config?.invoiceDefaults?.lineUnit;
+ return typeof lineUnit === 'string' && lineUnit.trim().length > 0
+ ? lineUnit.trim()
+ : undefined;
+ }
+
/**
* Resolve the connection-level default payment info (#1311) into the
* builder's neutral `Fa3PaymentInput` shape. Unlike `resolveSeller`, an
diff --git a/libs/integrations/ksef/src/domain/types/ksef-connection.types.ts b/libs/integrations/ksef/src/domain/types/ksef-connection.types.ts
index f5c24a6f2..cb560986d 100644
--- a/libs/integrations/ksef/src/domain/types/ksef-connection.types.ts
+++ b/libs/integrations/ksef/src/domain/types/ksef-connection.types.ts
@@ -102,6 +102,17 @@ export interface KsefPaymentConfig {
skonto?: { conditions: string; amount: string };
}
+/**
+ * Connection-level defaults applied to issued-document LINES (#1525) - a
+ * top-level config section (following the `payment` #1311 precedent, NOT on
+ * `KsefSellerConfig`, which mirrors Podmiot1 identity fields only).
+ * `lineUnit` is the free-text unit of measure emitted as `FaWiersz/P_8A` for
+ * any line whose neutral `unit` is absent; empty/cleared = P_8A not emitted.
+ */
+export interface KsefInvoiceDefaultsConfig {
+ lineUnit?: string;
+}
+
/** Non-secret config persisted on the connection row. */
export interface KsefConnectionConfig {
env: KsefEnvironment;
@@ -109,6 +120,8 @@ export interface KsefConnectionConfig {
seller?: KsefSellerConfig;
/** Default payment info emitted into `Fa/Platnosc` (#1311). Optional. */
payment?: KsefPaymentConfig;
+ /** Per-line issuance defaults (#1525). Optional. */
+ invoiceDefaults?: KsefInvoiceDefaultsConfig;
}
/**
diff --git a/libs/integrations/ksef/src/infrastructure/adapters/__tests__/ksef-connection-config-shape-validator.adapter.spec.ts b/libs/integrations/ksef/src/infrastructure/adapters/__tests__/ksef-connection-config-shape-validator.adapter.spec.ts
index 1dd3a9b42..bb1f8dc60 100644
--- a/libs/integrations/ksef/src/infrastructure/adapters/__tests__/ksef-connection-config-shape-validator.adapter.spec.ts
+++ b/libs/integrations/ksef/src/infrastructure/adapters/__tests__/ksef-connection-config-shape-validator.adapter.spec.ts
@@ -310,4 +310,66 @@ describe('KsefConnectionConfigShapeValidatorAdapter', () => {
).rejects.toBeInstanceOf(InvalidConnectionConfigException);
});
});
+
+ describe('invoiceDefaults.lineUnit (#1525)', () => {
+ it('should resolve when invoiceDefaults is absent', async () => {
+ await expect(validator.validate({ env: 'test' })).resolves.toBeUndefined();
+ });
+
+ it('should resolve for a valid lineUnit', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: 'szt.' } }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should resolve for an empty lineUnit (treated as absent, not an error)', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: '' } }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should resolve for a whitespace-only lineUnit (treated as absent)', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: ' ' } }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should resolve for a lineUnit of exactly 20 characters after trim', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: ` ${'x'.repeat(20)} ` } }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should reject a lineUnit longer than 20 characters after trim', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: 'x'.repeat(21) } }),
+ ).rejects.toBeInstanceOf(InvalidConnectionConfigException);
+ });
+
+ it('should reject a non-string lineUnit', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: { lineUnit: 5 } }),
+ ).rejects.toBeInstanceOf(InvalidConnectionConfigException);
+ });
+
+ it('should reject a non-object invoiceDefaults', async () => {
+ await expect(
+ validator.validate({ env: 'test', invoiceDefaults: 'szt.' }),
+ ).rejects.toBeInstanceOf(InvalidConnectionConfigException);
+ });
+
+ it('should carry a flat { path, message } issue for invoiceDefaults.lineUnit', async () => {
+ expect.assertions(1);
+ try {
+ await validator.validate({ env: 'test', invoiceDefaults: { lineUnit: 'x'.repeat(21) } });
+ } catch (error) {
+ expect((error as InvalidConnectionConfigException).errors).toEqual([
+ {
+ path: 'invoiceDefaults.lineUnit',
+ message: 'must be at most 20 characters after trimming',
+ },
+ ]);
+ }
+ });
+ });
});
diff --git a/libs/integrations/ksef/src/infrastructure/adapters/ksef-connection-config-shape-validator.adapter.ts b/libs/integrations/ksef/src/infrastructure/adapters/ksef-connection-config-shape-validator.adapter.ts
index 40b612474..e5dd91004 100644
--- a/libs/integrations/ksef/src/infrastructure/adapters/ksef-connection-config-shape-validator.adapter.ts
+++ b/libs/integrations/ksef/src/infrastructure/adapters/ksef-connection-config-shape-validator.adapter.ts
@@ -150,6 +150,31 @@ export class KsefConnectionConfigShapeValidatorAdapter
}
}
+ // Per-line issuance defaults (#1525). `lineUnit` is free text emitted as
+ // FaWiersz/P_8A; an empty/whitespace-only value is treated as absent (the
+ // factory's `resolveDefaultLineUnit` drops it), so only a wrong type or an
+ // over-long value is rejected. The 20-char cap is a sanity limit, not an
+ // XSD constraint (P_8A is unbounded TZnakowy).
+ const invoiceDefaults = config.invoiceDefaults;
+ if (
+ invoiceDefaults !== undefined &&
+ (invoiceDefaults === null || typeof invoiceDefaults !== 'object')
+ ) {
+ issues.push({ path: 'invoiceDefaults', message: 'must be an object' });
+ } else if (invoiceDefaults !== undefined) {
+ const lineUnit = (invoiceDefaults as Record).lineUnit;
+ if (lineUnit !== undefined) {
+ if (typeof lineUnit !== 'string') {
+ issues.push({ path: 'invoiceDefaults.lineUnit', message: 'must be a string' });
+ } else if (lineUnit.trim().length > 20) {
+ issues.push({
+ path: 'invoiceDefaults.lineUnit',
+ message: 'must be at most 20 characters after trimming',
+ });
+ }
+ }
+ }
+
if (issues.length > 0) {
return Promise.reject(new InvalidConnectionConfigException(this.pluginName, issues));
}
diff --git a/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing-adapter.types.ts b/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing-adapter.types.ts
index 4472c12f9..74cb96519 100644
--- a/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing-adapter.types.ts
+++ b/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing-adapter.types.ts
@@ -19,6 +19,12 @@ export interface KsefInvoicingAdapterOptions {
* `Platnosc` entirely.
*/
payment?: Fa3PaymentInput;
+ /**
+ * Connection-level default unit of measure (#1525) applied to any line whose
+ * neutral `unit` is absent - emitted as `FaWiersz/P_8A`. Omitted when the
+ * connection has none configured, in which case unit-less lines omit `P_8A`.
+ */
+ defaultLineUnit?: string;
/** Injected clock so the adapter (and its FA(3) timestamps) stay testable. */
now?: () => Date;
}
diff --git a/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing.adapter.ts b/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing.adapter.ts
index b894070f3..d63ccef6f 100644
--- a/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing.adapter.ts
+++ b/libs/integrations/ksef/src/infrastructure/adapters/ksef-invoicing.adapter.ts
@@ -129,6 +129,12 @@ export class KsefInvoicingAdapter
*/
private readonly payment: Fa3PaymentInput | undefined;
+ /**
+ * Connection-level default unit of measure (`P_8A`, #1525) - `undefined`
+ * when none is configured; unit-less lines then omit the element.
+ */
+ private readonly defaultLineUnit: string | undefined;
+
/** Injected clock so the adapter (and its FA(3) timestamps) stay testable. */
private readonly now: () => Date;
@@ -151,6 +157,7 @@ export class KsefInvoicingAdapter
options: KsefInvoicingAdapterOptions = {},
) {
this.payment = options.payment;
+ this.defaultLineUnit = options.defaultLineUnit;
this.now = options.now ?? ((): Date => new Date());
}
@@ -200,6 +207,7 @@ export class KsefInvoicingAdapter
generatedAt: issuedAt.toISOString(),
invoiceNumber: documentNumber,
defaultTaxRate: this.defaultTaxRate,
+ defaultLineUnit: this.defaultLineUnit,
payment: this.payment,
}),
);
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.spec.ts b/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.spec.ts
index 6b1053aa0..b6eddff34 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.spec.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.spec.ts
@@ -510,4 +510,111 @@ describe('buildFa3Xml', () => {
expect(() => validateFa3Xml(buildFa3Xml(b2bInput()))).not.toThrow();
});
});
+
+ describe('P_6 sale date (#1525)', () => {
+ it('should emit P_6 between P_2 and the P_13_x aggregates when saleDate is present', () => {
+ const input = b2bInput();
+ input.saleDate = '2026-06-20';
+ const xml = buildFa3Xml(input);
+ expect(xml).toContain('2026-06-20');
+ expect(xml).toMatch(/[^]*<\/P_2>2026-06-20<\/P_6>/);
+ });
+
+ it('should emit P_6 even when it equals P_1', () => {
+ const input = b2bInput();
+ input.saleDate = input.issueDate;
+ const xml = buildFa3Xml(input);
+ expect(xml).toContain(`${input.issueDate}`);
+ });
+
+ it('should omit P_6 entirely when saleDate is absent', () => {
+ expect(buildFa3Xml(b2bInput())).not.toContain('');
+ });
+
+ it('should pass the structural FA(3) validator with P_6 present', () => {
+ const input = b2bInput();
+ input.saleDate = '2026-06-20';
+ expect(() => validateFa3Xml(buildFa3Xml(input))).not.toThrow();
+ });
+ });
+
+ describe('P_9A net unit price (#1525)', () => {
+ it('should equal P_11 for quantity 1', () => {
+ const input = b2bInput();
+ input.lines = [{ name: 'A', quantity: 1, unitPriceGross: 123, p12: '23' }];
+ const xml = buildFa3Xml(input);
+ expect(xml).toContain('100.00');
+ expect(xml).toContain('100.00');
+ });
+
+ it('should carry the documented rounding drift for quantity > 1 (net 100.00 / qty 3)', () => {
+ const input = b2bInput();
+ // gross = 3 x 41 = 123, net = 123 / 1.23 = 100.00; P_9A = 100 / 3 = 33.33
+ // so P_9A x P_8B = 99.99 differs from P_11 = 100.00 by a cent - the
+ // accepted drift; P_11 stays authoritative.
+ input.lines = [{ name: 'A', quantity: 3, unitPriceGross: 41, p12: '23' }];
+ const xml = buildFa3Xml(input);
+ expect(xml).toContain('33.33');
+ expect(xml).toContain('100.00');
+ });
+
+ it('should emit P_9A on KOR before AND after rows with the same formula', () => {
+ const input: Fa3BuilderInput = {
+ ...b2bInput(),
+ lines: [{ name: 'Widget', quantity: 2, unitPriceGross: 123.0, p12: '23' }],
+ correction: {
+ typKorekty: '2',
+ reason: 'Return',
+ originalIssueDate: '2026-05-01',
+ originalInvoiceNumber: 'FV/2026/05/0042',
+ originalKsefNumber: null,
+ correctedLines: [{ name: 'Widget', quantity: 1, unitPriceGross: 123.0, p12: '23' }],
+ },
+ };
+ const xml = buildFa3Xml(input);
+ // Both rows share the same unit price, so both emit the same P_9A.
+ expect(xml.match(/100\.00<\/P_9A>/g)?.length).toBe(2);
+ });
+
+ it('should omit P_9A (never emit NaN) for a zero-quantity line', () => {
+ const input: Fa3BuilderInput = {
+ ...b2bInput(),
+ lines: [{ name: 'Widget', quantity: 1, unitPriceGross: 123.0, p12: '23' }],
+ correction: {
+ typKorekty: '2',
+ reason: 'Full return',
+ originalIssueDate: '2026-05-01',
+ originalInvoiceNumber: 'FV/2026/05/0042',
+ originalKsefNumber: null,
+ // A full return zeroes the "after" row - quantity 0 must not divide
+ // P_9A to NaN (KSeF would reject the literal at clearance).
+ correctedLines: [{ name: 'Widget', quantity: 0, unitPriceGross: 123.0, p12: '23' }],
+ },
+ };
+ const xml = buildFa3Xml(input);
+ expect(xml).not.toContain('NaN');
+ // Exactly one P_9A: the "before" row's. The zero-quantity row omits it.
+ expect(xml.match(//g)?.length).toBe(1);
+ });
+ });
+
+ describe('P_8A unit of measure (#1525)', () => {
+ it('should emit P_8A between P_7 and P_8B when the line carries a unit', () => {
+ const input = b2bInput();
+ input.lines = [{ name: 'Widget', quantity: 2, unitPriceGross: 123.45, p12: '23', unit: 'szt.' }];
+ const xml = buildFa3Xml(input);
+ expect(xml).toMatch(/Widget<\/P_7>szt\.<\/P_8A>2<\/P_8B>/);
+ });
+
+ it('should omit P_8A when the line carries no unit', () => {
+ expect(buildFa3Xml(b2bInput())).not.toContain('');
+ });
+
+ it('should pass the structural FA(3) validator with P_8A + P_9A present', () => {
+ const input = b2bInput();
+ input.saleDate = '2026-06-23';
+ input.lines = [{ name: 'Widget', quantity: 2, unitPriceGross: 123.45, p12: '23', unit: 'kg' }];
+ expect(() => validateFa3Xml(buildFa3Xml(input))).not.toThrow();
+ });
+ });
});
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.ts b/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.ts
index aa399d6d7..47b95644c 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/builders/fa3-xml.builder.ts
@@ -175,7 +175,23 @@ function lineNode(line: Fa3Line, ordinal: number, stanPrzed = false): XmlNodeObj
const node: XmlNodeObject = {
NrWierszaFa: ordinal,
P_7: line.name,
+ // P_8A (unit of measure) precedes P_8B in the FaWiersz sequence (XSD line
+ // ~3129); already precedence-resolved by the mapper, absent = omitted.
+ ...(line.unit !== undefined ? { P_8A: line.unit } : {}),
P_8B: quantity(line.quantity),
+ // P_9A is the NET unit price, derived from the SAME `lineNet` source as
+ // P_11 / the P_13_x band aggregation so the three can never drift. Rounded
+ // to 2dp: for quantity > 1 the rounded P_9A times P_8B may differ from
+ // P_11 by cents (e.g. net 100.00 / qty 3 -> P_9A 33.33, x3 = 99.99) - an
+ // accepted, documented drift; P_11 remains authoritative (#1525). Applies
+ // to KOR before/after rows identically (no correction special-casing).
+ // Guarded to quantity > 0: a non-positive quantity (a zero-quantity KOR
+ // "after" row, or a malformed snapshot's 0 default) would divide to NaN
+ // and render a literal "NaN" that the local structural validator cannot
+ // catch but KSeF rejects at clearance - the optional element is omitted
+ // instead (defense-in-depth with the command composer's positive-quantity
+ // gate, which covers only the plain-issue path).
+ ...(line.quantity > 0 ? { P_9A: money(lineNet(line) / line.quantity) } : {}),
// P_11 is the line's NET sale value — never the gross. Shared with the
// band aggregation via `lineNet` so the two can't diverge.
P_11: money(lineNet(line)),
@@ -447,6 +463,10 @@ function faNode(input: Fa3BuilderInput): XmlNodeObject {
KodWaluty: input.currency,
P_1: input.issueDate,
P_2: input.invoiceNumber,
+ // P_6 (date of supply / sale) sits in the optional choice right after
+ // P_2/WZ and before the P_13_x aggregates (XSD line ~2471). Emitted
+ // whenever known - including when equal to P_1 (#1525).
+ ...(input.saleDate !== undefined ? { P_6: input.saleDate } : {}),
...bands,
P_15: grandTotal,
Adnotacje: adnotacjeNode(),
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.spec.ts b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.spec.ts
index b5c922357..f765428b0 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.spec.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.spec.ts
@@ -129,3 +129,56 @@ describe('mapToFa3BuilderInput — payment (#1311)', () => {
expect(result.payment).toEqual({ formaPlatnosci: '6', paymentTermDays: 14 });
});
});
+
+describe('mapToFa3BuilderInput - sale date P_6 (#1525)', () => {
+ it('should pass the neutral saleDate through to the builder input', () => {
+ const result = mapToFa3BuilderInput({ ...baseCommand('23'), saleDate: '2026-06-20' }, CONTEXT);
+ expect(result.saleDate).toBe('2026-06-20');
+ });
+
+ it('should leave saleDate off the builder input when the command has none', () => {
+ const result = mapToFa3BuilderInput(baseCommand('23'), CONTEXT);
+ expect(result.saleDate).toBeUndefined();
+ expect('saleDate' in result).toBe(false);
+ });
+});
+
+describe('mapToFa3BuilderInput - line unit P_8A precedence (#1525)', () => {
+ it('should keep the line unit when the neutral line carries one (wins over the default)', () => {
+ const cmd = baseCommand('23');
+ cmd.lines = [{ name: 'Widget', quantity: 1, unitPriceGross: 100, taxRate: '23', unit: 'kg' }];
+ const result = mapToFa3BuilderInput(cmd, { ...CONTEXT, defaultLineUnit: 'szt.' });
+ expect(result.lines[0].unit).toBe('kg');
+ });
+
+ it('should fall back to the connection defaultLineUnit when the line has none', () => {
+ const result = mapToFa3BuilderInput(baseCommand('23'), { ...CONTEXT, defaultLineUnit: 'szt.' });
+ expect(result.lines[0].unit).toBe('szt.');
+ });
+
+ it('should omit unit entirely when neither the line nor the connection has one', () => {
+ const result = mapToFa3BuilderInput(baseCommand('23'), CONTEXT);
+ expect(result.lines[0].unit).toBeUndefined();
+ expect('unit' in result.lines[0]).toBe(false);
+ });
+
+ it('should treat an empty/whitespace line unit as absent and use the default', () => {
+ const cmd = baseCommand('23');
+ cmd.lines = [{ name: 'Widget', quantity: 1, unitPriceGross: 100, taxRate: '23', unit: ' ' }];
+ const result = mapToFa3BuilderInput(cmd, { ...CONTEXT, defaultLineUnit: 'kpl.' });
+ expect(result.lines[0].unit).toBe('kpl.');
+ });
+
+ it('should apply the same unit precedence to correction lines', () => {
+ const cmd = baseCommand('23');
+ cmd.correction = {
+ originalClearanceReference: null,
+ originalDocumentNumber: 'FA/2026/06/0001',
+ originalIssueDate: '2026-06-01',
+ reason: 'Return',
+ correctedLines: [{ name: 'Widget', quantity: 1, unitPriceGross: 90, taxRate: '23' }],
+ };
+ const result = mapToFa3BuilderInput(cmd, { ...CONTEXT, defaultLineUnit: 'szt.' });
+ expect(result.correction?.correctedLines[0].unit).toBe('szt.');
+ });
+});
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.ts b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.ts
index add7d4f5a..b1014cf16 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-builder-input.mapper.ts
@@ -59,6 +59,13 @@ export interface Fa3MappingContext {
* even though both are resolved from the same connection config.
*/
defaultTaxRate: string;
+ /**
+ * Connection-resolved fallback unit of measure (`P_8A`, #1525) applied to any
+ * line whose neutral `unit` is absent/empty. `undefined` when the connection
+ * has none configured - such lines omit `P_8A` entirely. Precedence:
+ * `InvoiceLine.unit` -> this default -> omit.
+ */
+ defaultLineUnit?: string;
/**
* Resolved connection-level payment defaults (#1311) — `undefined` when the
* connection has none configured, in which case the builder omits
@@ -89,9 +96,12 @@ export function mapToFa3BuilderInput(
issueDate: context.issueDate,
invoiceNumber: context.invoiceNumber,
generatedAt: context.generatedAt,
- lines: cmd.lines.map((line) => mapLine(line, context.defaultTaxRate)),
+ // P_6 is emitted whenever the neutral command knows the sale date - even
+ // when it equals P_1 (#1525); absence means the source order carried none.
+ ...(cmd.saleDate !== undefined ? { saleDate: cmd.saleDate } : {}),
+ lines: cmd.lines.map((line) => mapLine(line, context)),
...(cmd.correction !== undefined
- ? { correction: mapCorrection(cmd.correction, context.defaultTaxRate) }
+ ? { correction: mapCorrection(cmd.correction, context) }
: {}),
...(context.payment !== undefined ? { payment: context.payment } : {}),
};
@@ -116,12 +126,17 @@ function normalizeAddressCountry(address: BuyerAddress): BuyerAddress {
return { ...address, countryIso2: resolveKodKraju(address.countryIso2) };
}
-function mapLine(line: InvoiceLine, defaultTaxRate: string): Fa3Line {
+function mapLine(line: InvoiceLine, context: Fa3MappingContext): Fa3Line {
+ // P_8A precedence (#1525): the line's own unit wins, else the connection's
+ // configured default, else the element is omitted (empty/whitespace counts
+ // as absent so a hollow value never reaches the wire).
+ const unit = line.unit?.trim() || context.defaultLineUnit?.trim() || undefined;
return {
name: line.name,
quantity: line.quantity,
unitPriceGross: line.unitPriceGross,
- p12: resolveP12(line.taxRate || defaultTaxRate),
+ p12: resolveP12(line.taxRate || context.defaultTaxRate),
+ ...(unit !== undefined ? { unit } : {}),
};
}
@@ -135,7 +150,7 @@ function mapLine(line: InvoiceLine, defaultTaxRate: string): Fa3Line {
*/
function mapCorrection(
correction: CorrectionReference,
- defaultTaxRate: string,
+ context: Fa3MappingContext,
): Fa3CorrectionContext {
return {
typKorekty: '2',
@@ -143,6 +158,6 @@ function mapCorrection(
originalIssueDate: correction.originalIssueDate,
originalInvoiceNumber: correction.originalDocumentNumber,
originalKsefNumber: correction.originalClearanceReference,
- correctedLines: correction.correctedLines.map((line) => mapLine(line, defaultTaxRate)),
+ correctedLines: correction.correctedLines.map((line) => mapLine(line, context)),
};
}
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-xml.types.ts b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-xml.types.ts
index 5a050b760..23477482c 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-xml.types.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/domain/fa3-xml.types.ts
@@ -81,6 +81,12 @@ export interface Fa3Line {
quantity: number;
unitPriceGross: number;
p12: Fa3P12Value;
+ /**
+ * Unit of measure (`P_8A`, `TZnakowy` free text, #1525). Resolved by the
+ * builder-input mapper with precedence: neutral `InvoiceLine.unit` -> the
+ * connection's `invoiceDefaults.lineUnit` -> absent (element omitted).
+ */
+ unit?: string;
}
/**
@@ -187,6 +193,62 @@ export const FA3_RACHUNEK_BANKOWY_CHILD_ORDER = [
'OpisRachunku',
] as const;
+/**
+ * XSD-mandated child order of the `Fa` sequence (FA(3) v1-0E, XSD line ~2439),
+ * restricted to the elements the builder can emit (#1525 review). Notably `P_6`
+ * (the optional sale-date choice) sits between `P_2` and the `P_13_x`/`P_14_x`
+ * band aggregates - a position regression fails the local validator instead of
+ * KSeF clearance. Same flat first-occurrence caveat as the other order lists:
+ * none of these names may also occur nested inside another listed element's
+ * subtree (true for the builder's output - FaWiersz children are `P_7`/`P_8A`/
+ * `P_8B`/`P_9A`/`P_11`/`P_12`, disjoint from this list).
+ */
+export const FA3_FA_CHILD_ORDER = [
+ 'KodWaluty',
+ 'P_1',
+ 'P_2',
+ 'P_6',
+ 'P_13_1',
+ 'P_14_1',
+ 'P_13_2',
+ 'P_14_2',
+ 'P_13_3',
+ 'P_14_3',
+ 'P_13_6_1',
+ 'P_13_6_2',
+ 'P_13_6_3',
+ 'P_13_7',
+ 'P_13_8',
+ 'P_13_9',
+ 'P_13_10',
+ 'P_15',
+ 'Adnotacje',
+ 'RodzajFaktury',
+ 'PrzyczynaKorekty',
+ 'TypKorekty',
+ 'DaneFaKorygowanej',
+ 'FaWiersz',
+ 'Platnosc',
+] as const;
+
+/**
+ * XSD-mandated child order of the `FaWiersz` sequence (FA(3) v1-0E, XSD line
+ * ~3080), restricted to the elements the builder can emit (#1525). Notably
+ * `P_8A` (unit of measure) comes immediately before `P_8B` (quantity), and
+ * `P_9A` (net unit price) immediately after `P_8B`; the `StanPrzed` KOR flag
+ * closes the sequence. Absent optional elements are simply skipped.
+ */
+export const FA3_FA_WIERSZ_CHILD_ORDER = [
+ 'NrWierszaFa',
+ 'P_7',
+ 'P_8A',
+ 'P_8B',
+ 'P_9A',
+ 'P_11',
+ 'P_12',
+ 'StanPrzed',
+] as const;
+
/**
* Fully-mapped builder input. The adapter produces this from a neutral
* `IssueInvoiceCommand` (applying the tax-rate, buyer-id and currency mappers)
@@ -206,6 +268,13 @@ export interface Fa3BuilderInput {
issueDate: string;
/** Invoice number (`P_2`) — the human-facing sequential document number. */
invoiceNumber: string;
+ /**
+ * Date of supply / sale (`P_6`), ISO-8601 calendar date `YYYY-MM-DD` (#1525).
+ * Emitted whenever known - including when equal to `P_1` - at the
+ * XSD-mandated position (the optional choice after `P_2`/`WZ`, XSD line
+ * ~2471). Absent when the neutral command carries no sale date.
+ */
+ saleDate?: string;
/**
* Document-generation timestamp (`DataWytworzeniaFa`), ISO-8601 UTC instant
* ending in `Z`. Part of the input so the builder stays pure — no `Date.now()`.
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.spec.ts b/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.spec.ts
index a94d6a1ac..36c1a6632 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.spec.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.spec.ts
@@ -164,6 +164,63 @@ describe('validateFa3Xml', () => {
}
});
+ it('should reject a P_6 emitted after the P_13_x aggregates (#1525 Fa-order guard)', () => {
+ // Simulate a builder regression by moving P_6 after P_13_1 in a valid doc.
+ const withSaleDate = buildFa3Xml({
+ seller,
+ buyer: { kind: 'nip', nip: '9876543210' },
+ buyerName: 'Buyer GmbH',
+ buyerAddress: { line1: 'Main St 5', line2: null, city: 'Berlin', postalCode: '10115', countryIso2: 'DE' },
+ currency: 'PLN',
+ issueDate: '2026-06-23',
+ invoiceNumber: 'FV/2026/06/0001',
+ generatedAt: '2026-06-23T10:15:30Z',
+ saleDate: '2026-06-20',
+ lines: [{ name: 'Widget', quantity: 2, unitPriceGross: 123.45, p12: '23' }],
+ });
+ const bad = withSaleDate.replace(
+ /([^<]*<\/P_6>)([^<]*<\/P_13_1>)/,
+ '$2$1',
+ ) as RawFa3Xml;
+ expect(bad).not.toEqual(withSaleDate);
+ try {
+ validateFa3Xml(bad);
+ fail('expected Fa3XsdValidationException');
+ } catch (error) {
+ expect(error).toBeInstanceOf(Fa3XsdValidationException);
+ const paths = (error as Fa3XsdValidationException).issues.map((i) => i.path);
+ expect(paths).toContain('/Faktura/Fa/P_13_1');
+ }
+ });
+
+ it('should reject a FaWiersz that emits P_8A after P_8B (#1525 ordering guard)', () => {
+ const input: Fa3BuilderInput = {
+ seller,
+ buyer: { kind: 'nip', nip: '9876543210' },
+ buyerName: 'Buyer GmbH',
+ buyerAddress: { line1: 'Main St 5', line2: null, city: 'Berlin', postalCode: '10115', countryIso2: 'DE' },
+ currency: 'PLN',
+ issueDate: '2026-06-23',
+ invoiceNumber: 'FV/2026/06/0001',
+ generatedAt: '2026-06-23T10:15:30Z',
+ lines: [{ name: 'Widget', quantity: 2, unitPriceGross: 123.45, p12: '23', unit: 'szt.' }],
+ };
+ const doc = buildFa3Xml(input);
+ const bad = doc.replace(
+ /([^<]*<\/P_8A>)([^<]*<\/P_8B>)/,
+ '$2$1',
+ ) as RawFa3Xml;
+ expect(bad).not.toEqual(doc);
+ try {
+ validateFa3Xml(bad);
+ fail('expected Fa3XsdValidationException');
+ } catch (error) {
+ expect(error).toBeInstanceOf(Fa3XsdValidationException);
+ const paths = (error as Fa3XsdValidationException).issues.map((i) => i.path);
+ expect(paths).toContain('/Faktura/Fa/FaWiersz/P_8B');
+ }
+ });
+
it('should not embed the raw XML in the exception message', () => {
const bad = 'SECRET_BUYER_NAME' as RawFa3Xml;
try {
diff --git a/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.ts b/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.ts
index 3b63ce5ad..1494b08a6 100644
--- a/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.ts
+++ b/libs/integrations/ksef/src/infrastructure/fa3/validators/fa3-xsd.validator.ts
@@ -24,6 +24,8 @@ import {
type Fa3ValidationIssue,
} from '../../../domain/exceptions/fa3-validation.exception';
import {
+ FA3_FA_CHILD_ORDER,
+ FA3_FA_WIERSZ_CHILD_ORDER,
FA3_NAMESPACE,
FA3_PLATNOSC_CHILD_ORDER,
FA3_RACHUNEK_BANKOWY_CHILD_ORDER,
@@ -120,6 +122,21 @@ export function validateFa3Xml(xml: RawFa3Xml): void {
if (!/]/.test(xml)) {
issues.push({ path: `${root}/Fa/FaWiersz`, message: 'Fa must contain at least one FaWiersz line' });
}
+ // `Fa` child ordering (#1525 review): notably P_6 must sit between P_2 and
+ // the P_13_x aggregates - a position regression fails here instead of at
+ // KSeF clearance. Flat first-occurrence scan over the Fa block; the listed
+ // names do not recur nested inside Fa's children (see FA3_FA_CHILD_ORDER).
+ const faMatch = xml.match(/([^]*?)<\/Fa>/);
+ if (faMatch !== null) {
+ pushChildOrderIssues(faMatch[1], FA3_FA_CHILD_ORDER, `${root}/Fa`, issues);
+ }
+ // `FaWiersz` child ordering (#1525): the XSD mandates a fixed sequence
+ // (notably P_8A immediately before P_8B and P_9A immediately after) - a
+ // wrong-order row is rejected by KSeF at clearance, and the presence-only
+ // checks above would not catch a builder ordering regression.
+ for (const row of xml.matchAll(/([^]*?)<\/FaWiersz>/g)) {
+ pushChildOrderIssues(row[1], FA3_FA_WIERSZ_CHILD_ORDER, `${root}/Fa/FaWiersz`, issues);
+ }
}
// 2b. `Fa/Platnosc` child ordering (#1311, PR #1317 review). The XSD mandates