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=. */} + + {KSEF_LINE_UNIT_SUGGESTIONS.map((unit) => ( + + 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=. */} + + {KSEF_LINE_UNIT_SUGGESTIONS.map((unit) => ( + ); } 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