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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/invoicing/http/invoicing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
IInvoiceService,
toIssueInvoiceCommand,
InvalidBuyerProfileError,
InvalidInvoiceLineError,
UnsupportedPriceTreatmentError,
DuplicateInvoiceRecordException,
InvoiceRecordNotFoundException,
Expand Down Expand Up @@ -1137,6 +1138,7 @@ export class InvoicingController {
}
if (
error instanceof InvalidBuyerProfileError ||
error instanceof InvalidInvoiceLineError ||
error instanceof UnsupportedPriceTreatmentError
) {
return new BadRequestException(error.message);
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/plugins/ksef/components/ksef-setup-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<KsefSetupForm />, { 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<string, unknown> };
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(<KsefSetupForm />, { 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<string, unknown> };
expect('invoiceDefaults' in payload.config).toBe(false);
});
});
22 changes: 22 additions & 0 deletions apps/web/src/plugins/ksef/components/ksef-setup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -254,6 +255,27 @@ export function KsefSetupForm(): ReactElement {
/>
</FormField>

<FormField
label="Default line unit"
name="invoiceDefaultLineUnit"
error={form.formState.errors.invoiceDefaultLineUnit?.message}
description="Unit of measure stamped on issued-invoice lines. Clear it to omit the unit from documents."
>
<Input
{...form.register('invoiceDefaultLineUnit')}
list="ksef-line-unit-suggestions"
placeholder="szt."
autoComplete="off"
invalid={Boolean(form.formState.errors.invoiceDefaultLineUnit)}
/>
</FormField>
{/* Outside FormField (it requires a single child); linked via list=. */}
<datalist id="ksef-line-unit-suggestions">
{KSEF_LINE_UNIT_SUGGESTIONS.map((unit) => (
<option key={unit} value={unit} />
))}
</datalist>

<FormField
label="Authentication type"
name="authType"
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/plugins/ksef/components/ksef-setup.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ export const ksefSetupSchema = z
])
.optional(),
contextIdentifier: z.string().trim().max(64).optional(),
// Default unit of measure stamped on issued-document lines (#1525) -
// free text, max 20 chars after trim; empty = the unit element is not
// emitted. Prefilled 'szt.' on creation, clearable.
invoiceDefaultLineUnit: z
.string()
.trim()
.max(20, 'Default line unit must be at most 20 characters.')
.optional(),
authType: z.enum(KSEF_AUTH_TYPE_VALUES),
// Write-only secret (KSeF authorization token / qualified-seal reference).
secret: z.string().trim().min(1, 'Authentication secret is required'),
Expand Down Expand Up @@ -141,10 +149,19 @@ export const KSEF_SETUP_DEFAULT_VALUES: KsefSetupFormValues = {
sellerPostalCode: '',
sellerCountryIso2: 'PL',
contextIdentifier: '',
// Prefilled per #1525 - 'szt.' (pieces) is the overwhelmingly common PL
// retail unit; the operator can clear it to stop unit emission entirely.
invoiceDefaultLineUnit: 'szt.',
authType: 'ksef-token',
secret: '',
};

/**
* Suggested units for the default-line-unit datalist (#1525) - free text is
* still allowed; these are just the common PL invoice units.
*/
export const KSEF_LINE_UNIT_SUGGESTIONS = ['szt.', 'kg', 'godz.', 'usł.', 'kpl.', 'm'] as const;

// `buildKsefSellerConfig` (assembly) lives in the shared `ksef-seller-config`
// module so the create path here and the edit path
// (`edit-connection.schema.ts`) normalize + assemble the nested `config.seller`
Expand All @@ -158,6 +175,10 @@ export function toCreateConnectionInput(values: KsefSetupFormSubmission): Create
if (values.contextIdentifier && values.contextIdentifier.length > 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -304,6 +308,28 @@ export function KsefStructuredSection({
invalid={Boolean(form.formState.errors.paymentSkontoAmount)}
/>
</FormField>
<FormField
label="Default line unit"
name="invoiceDefaultLineUnit"
error={form.formState.errors.invoiceDefaultLineUnit?.message}
description="Unit of measure stamped on issued-invoice lines (config.invoiceDefaults.lineUnit). Clear it to omit the unit from documents."
>
<Input
value={form.watch('invoiceDefaultLineUnit') ?? ''}
onChange={(event) => syncStructuredToJson('invoiceDefaultLineUnit', event.target.value)}
list="ksef-edit-line-unit-suggestions"
placeholder="szt."
autoComplete="off"
disabled={!configIsParseable}
invalid={Boolean(form.formState.errors.invoiceDefaultLineUnit)}
/>
</FormField>
{/* Outside FormField (it requires a single child); linked via list=. */}
<datalist id="ksef-edit-line-unit-suggestions">
{KSEF_LINE_UNIT_SUGGESTIONS.map((unit) => (
<option key={unit} value={unit} />
))}
</datalist>
</>
);
}
64 changes: 64 additions & 0 deletions apps/web/src/plugins/ksef/ksef-connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ describe('ksefConnectionConfig.readConfigToForm — hydration', () => {
paymentTermDays: '14',
paymentSkontoConditions: 'text',
paymentSkontoAmount: '2%',
invoiceDefaultLineUnit: '',
});
});

Expand All @@ -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);
});
});
58 changes: 58 additions & 0 deletions apps/web/src/plugins/ksef/ksef-connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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<string, unknown>, ctx: z.RefinementCtx): void {
Expand Down Expand Up @@ -288,6 +295,23 @@ function readKsefPayment(config: Record<string, unknown>): {
};
}

/**
* 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<string, unknown>): {
invoiceDefaultLineUnit: string;
} {
const invoiceDefaults =
typeof config.invoiceDefaults === 'object' && config.invoiceDefaults !== null
? (config.invoiceDefaults as Record<string, unknown>)
: {};
return {
invoiceDefaultLineUnit:
typeof invoiceDefaults.lineUnit === 'string' ? invoiceDefaults.lineUnit : '',
};
}

/** Rebuild the typed seller patch slice from an untyped structured patch. */
function toSellerPatch(patch: Record<string, unknown>): KsefSellerProfileInput {
return {
Expand Down Expand Up @@ -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<string, unknown>,
patch: Record<string, unknown>,
): Record<string, unknown> {
const lineUnit = readOptionalConfigString(patch, 'invoiceDefaultLineUnit');
if (lineUnit === undefined) {
return config;
}
const next: Record<string, unknown> = { ...config };
const invoiceDefaults: Record<string, unknown> =
typeof next.invoiceDefaults === 'object' && next.invoiceDefaults !== null
? { ...(next.invoiceDefaults as Record<string, unknown>) }
: {};
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;
}

Expand All @@ -355,6 +412,7 @@ export const ksefConnectionConfig: ConnectionConfigContribution = {
ksefEnvironment: readKsefEnvironment(config),
...readKsefSeller(config),
...readKsefPayment(config),
...readKsefInvoiceDefaults(config),
contextIdentifier: readConfigString(config, 'contextIdentifier'),
}),
applyToConfig: applyKsefConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/worker/src/sync/handlers/invoicing-issue.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading