fix(arsredovisning): unblock the signing flow, accept foreign parent org nr, explain Fortnox underlag failures (#1738)
Batch from a real migration walkthrough (Fortnox -> Accounted, 2026-08-20): - Årsredovisning: the "Låst version" select was empty with no explanation because the only version was a draft and "Lås version för underskrift" is disabled while the four Lagstadgade upplysningar checkboxes and the content confirmation count as blockers. The select is now disabled with a hint that names the blocker count and links to Fullständighetskontroll, the four AR-NOTE-*-UNCONFIRMED issues carry remediation text, the lock button explains why it is grey, and "Markera som signerad" says what it still needs (locked version, bevisreferens, date). - Moderföretagets org.nr accepts a foreign registration identifier (CHE-123.456.789, HRB 12345, 923 609 016); personnummer shapes stay out. - Fortnox underlag discovery: log status, body and Fortnox's message on failure, show the message in the UI, treat a 400 with behörighet/scope text as scopes-required, and fall back to an unfiltered voucherfileconnections list when the financialyear filter answers 400. - Kontomapping: the Momskod column had min-w only; table-fixed collapsed it and its selects overflowed into Konfidens. Real w-72 now. - SIE import warnings pluralise correctly for one skipped voucher; the Verifikationsserie option says the source series is preserved. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
5a8dd21931
commit
e733ab7c43
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidParentCompanyIdentifier } from '../parent-company-identifier'
|
||||
|
||||
describe('isValidParentCompanyIdentifier', () => {
|
||||
it('accepts Swedish organisationsnummer with and without the dash', () => {
|
||||
expect(isValidParentCompanyIdentifier('556677-8899')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('5566778899')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('16556677-8899')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier(' 559460-5627 ')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects personnummer-shaped values in both 10- and 12-digit form', () => {
|
||||
expect(isValidParentCompanyIdentifier('850101-1234')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('8501011234')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('19850101-1234')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('198501011234')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('20120101-1234')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts foreign registration identifiers as written in the home register', () => {
|
||||
expect(isValidParentCompanyIdentifier('CHE-123.456.789')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('CHE-123.456.789 MWST')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('923 609 016')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('HRB 12345')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('1234567-8')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('12345678')).toBe(true)
|
||||
expect(isValidParentCompanyIdentifier('NL12345678')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty, over-long and control-character values', () => {
|
||||
expect(isValidParentCompanyIdentifier('')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier(' ')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('A'.repeat(41))).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('CHE-123<script>')).toBe(false)
|
||||
expect(isValidParentCompanyIdentifier('-123')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -289,30 +289,37 @@ export function validateAnnualReportCompleteness(
|
||||
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
|
||||
}
|
||||
|
||||
const disclosureChecks: Array<[boolean, string, string]> = [
|
||||
// Each confirmation is a checkbox under "Lagstadgade upplysningar" on the
|
||||
// årsredovisning page, persisted by "Spara texten". Say so: a bare "är
|
||||
// inte bekräftad" left a real user hunting for the switch (2026-08-20).
|
||||
const disclosureChecks: Array<[boolean, string, string, string]> = [
|
||||
[
|
||||
disclosures.long_term_debt_over_five_years_confirmed,
|
||||
'AR-NOTE-LONG-DEBT-UNCONFIRMED',
|
||||
'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat uppgiften" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.securities_pledged_confirmed,
|
||||
'AR-NOTE-SECURITIES-UNCONFIRMED',
|
||||
'Uppgiften om ställda säkerheter är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat ställda säkerheter" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.contingent_liabilities_confirmed,
|
||||
'AR-NOTE-CONTINGENT-UNCONFIRMED',
|
||||
'Uppgiften om eventualförpliktelser är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat eventualförpliktelser" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
[
|
||||
disclosures.parent_company_confirmed,
|
||||
'AR-NOTE-PARENT-UNCONFIRMED',
|
||||
'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
|
||||
'Kryssa i "Jag har kontrollerat koncernförhållandet" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
|
||||
],
|
||||
]
|
||||
for (const [confirmed, code, message] of disclosureChecks) {
|
||||
if (!confirmed) push(issues, code, 'error', 'notes', message)
|
||||
for (const [confirmed, code, message, remediation] of disclosureChecks) {
|
||||
if (!confirmed) push(issues, code, 'error', 'notes', message, remediation)
|
||||
}
|
||||
|
||||
if (report.accounting_framework === 'k3') {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Validation for the moderföretag identifier in the årsredovisning note
|
||||
* (ÅRL 5 kap. 21 §: namn, organisationsnummer/motsvarande och säte).
|
||||
*
|
||||
* Two shapes are accepted:
|
||||
*
|
||||
* 1. Swedish organisationsnummer, NNNNNN-NNNN or the 12-digit 16NNNNNNNNNN
|
||||
* form, dash optional. The third digit must be 2-9: that is what separates
|
||||
* legal-entity numbers from personnummer (whose third digit is part of a
|
||||
* month, 0-1). Personnummer are out of scope for the disclosure and a GDPR
|
||||
* Art. 5(1)(c) data-minimisation concern if persisted, so both the 10-digit
|
||||
* and the century-prefixed 12-digit personnummer shapes are rejected.
|
||||
* 2. A foreign registration identifier as written in the home register:
|
||||
* CHE-123.456.789 (Switzerland), 923 609 016 (Norway), HRB 12345
|
||||
* (Germany), 1234567-8 (Finland), 12345678 (UK CRN, DK CVR) and so on.
|
||||
* Letters, digits, space, dot, comma, dash and slash, 2-40 characters.
|
||||
* A foreign 10- or 12-digit all-numeric value is treated as Swedish-shaped
|
||||
* and falls under rule 1; that false negative is accepted in exchange for
|
||||
* never storing a personnummer.
|
||||
*/
|
||||
|
||||
const FOREIGN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9 .,\-/]{1,39}$/
|
||||
|
||||
export function isValidParentCompanyIdentifier(raw: string): boolean {
|
||||
const value = raw.trim()
|
||||
if (!value) return false
|
||||
|
||||
const digitsOnly = value.replace(/[\s-]/g, '')
|
||||
const allDigits = /^\d+$/.test(digitsOnly)
|
||||
if (allDigits && (digitsOnly.length === 10 || digitsOnly.length === 12)) {
|
||||
if (digitsOnly.length === 12 && !digitsOnly.startsWith('16')) return false
|
||||
const core = digitsOnly.length === 12 ? digitsOnly.slice(2) : digitsOnly
|
||||
return /^\d{2}[2-9]\d{7}$/.test(core)
|
||||
}
|
||||
|
||||
return FOREIGN_IDENTIFIER.test(value)
|
||||
}
|
||||
|
||||
export const PARENT_COMPANY_IDENTIFIER_ERROR =
|
||||
'Ogiltigt organisationsnummer (svenskt NNNNNN-NNNN, ej personnummer). Utländskt moderföretag: ange registreringsnumret som det står i hemlandets register, t.ex. CHE-123.456.789.'
|
||||
@@ -2404,11 +2404,11 @@ export async function executeSIEImport(
|
||||
const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped
|
||||
if (totalSkipped > 0) {
|
||||
const parts: string[] = []
|
||||
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} tomma`)
|
||||
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} ${voucherResults.skippedEmpty === 1 ? 'tom' : 'tomma'}`)
|
||||
if (voucherResults.skippedUnbalanced > 0) parts.push(`${voucherResults.skippedUnbalanced} obalanserade`)
|
||||
if (voucherResults.skippedUnmapped > 0) parts.push(`${voucherResults.skippedUnmapped} med ej mappade konton`)
|
||||
result.warnings.push(
|
||||
`${totalSkipped} verifikationer hoppades över (ofullständiga i källsystemet): ${parts.join(', ')}`
|
||||
`${totalSkipped} ${totalSkipped === 1 ? 'verifikation' : 'verifikationer'} hoppades över (${totalSkipped === 1 ? 'ofullständig' : 'ofullständiga'} i källsystemet): ${parts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2419,7 +2419,7 @@ export async function executeSIEImport(
|
||||
.slice(0, 10)
|
||||
.map(d => d.voucherId)
|
||||
result.warnings.push(
|
||||
`${voucherResults.skippedSingleLine} enradsverifikationer hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
|
||||
`${voucherResults.skippedSingleLine} ${voucherResults.skippedSingleLine === 1 ? 'enradsverifikation' : 'enradsverifikationer'} hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,47 @@ describe('Fortnox attachments', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to one unfiltered listing when Fortnox rejects the financialyear filter with 400', async () => {
|
||||
const getPaginated = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
||||
.mockResolvedValueOnce([
|
||||
{ FileId: 'file-1', Name: 'kvitto.pdf', VoucherSeries: 'A', VoucherNumber: 12, VoucherYear: 3 },
|
||||
{ FileId: 'file-2', Name: 'faktura.png', VoucherSeries: 'B', VoucherNumber: 7, VoucherYear: 4 },
|
||||
{ FileId: 'file-9', Name: 'gammalt.pdf', VoucherSeries: 'A', VoucherNumber: 1, VoucherYear: 1 },
|
||||
]);
|
||||
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
||||
|
||||
await expect(fetchFortnoxFileConnections(client, 'token', [3, 4])).resolves.toEqual([
|
||||
{ fileId: 'file-1', name: 'kvitto.pdf', series: 'A', number: 12, financialYearId: 3 },
|
||||
{ fileId: 'file-2', name: 'faktura.png', series: 'B', number: 7, financialYearId: 4 },
|
||||
]);
|
||||
expect(getPaginated).toHaveBeenCalledTimes(2);
|
||||
expect(getPaginated).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
'/voucherfileconnections',
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates a 400 from the unfiltered fallback and non-400 failures untouched', async () => {
|
||||
const scopeError = new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, 'behörighet');
|
||||
const getPaginated = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new FortnoxApiError('Fortnox API error: 400 Bad Request', 400, ''))
|
||||
.mockRejectedValueOnce(scopeError);
|
||||
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
|
||||
await expect(fetchFortnoxFileConnections(client, 'token', [3])).rejects.toBe(scopeError);
|
||||
|
||||
const forbidden = new FortnoxApiError('forbidden', 403);
|
||||
const client403 = clientWith({
|
||||
getPaginated: vi.fn().mockRejectedValue(forbidden),
|
||||
} as Partial<FortnoxClient>);
|
||||
await expect(fetchFortnoxFileConnections(client403, 'token', [3])).rejects.toBe(forbidden);
|
||||
});
|
||||
|
||||
it('downloads through the archive path without fallback when it succeeds', async () => {
|
||||
const response = { bytes: new ArrayBuffer(2), contentType: 'application/pdf' };
|
||||
const getBinary = vi.fn().mockResolvedValue(response);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FortnoxApiError, fortnoxErrorMessage, isFortnoxPermissionError } from '../client';
|
||||
|
||||
describe('fortnoxErrorMessage', () => {
|
||||
it('reads ErrorInformation.message from a Fortnox error body', () => {
|
||||
const error = new FortnoxApiError(
|
||||
'Fortnox API error: 400 Bad Request',
|
||||
400,
|
||||
'{"ErrorInformation":{"error":1,"message":"Kan inte hitta kontot.","code":2000423}}',
|
||||
);
|
||||
expect(fortnoxErrorMessage(error)).toBe('Kan inte hitta kontot.');
|
||||
});
|
||||
|
||||
it('falls back to the raw body when it is not JSON and to null when empty', () => {
|
||||
expect(fortnoxErrorMessage(new FortnoxApiError('x', 500, 'Gateway timeout'))).toBe(
|
||||
'Gateway timeout',
|
||||
);
|
||||
expect(fortnoxErrorMessage(new FortnoxApiError('x', 500, ''))).toBeNull();
|
||||
expect(fortnoxErrorMessage(new Error('not fortnox'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFortnoxPermissionError', () => {
|
||||
it('treats 403 as a permission failure regardless of body', () => {
|
||||
expect(isFortnoxPermissionError(new FortnoxApiError('forbidden', 403))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 400 with a behörighet/scope/licens message as a permission failure', () => {
|
||||
const error = new FortnoxApiError(
|
||||
'Fortnox API error: 400 Bad Request',
|
||||
400,
|
||||
'{"ErrorInformation":{"error":1,"message":"Du saknar behörighet till denna resurs.","code":2000663}}',
|
||||
);
|
||||
expect(isFortnoxPermissionError(error)).toBe(true);
|
||||
expect(
|
||||
isFortnoxPermissionError(
|
||||
new FortnoxApiError('x', 400, '{"ErrorInformation":{"message":"Invalid scope"}}'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify an ordinary 400 or a non-Fortnox error as permission', () => {
|
||||
expect(
|
||||
isFortnoxPermissionError(
|
||||
new FortnoxApiError('x', 400, '{"ErrorInformation":{"message":"Kan inte hitta kontot."}}'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isFortnoxPermissionError(new FortnoxApiError('x', 400, ''))).toBe(false);
|
||||
expect(isFortnoxPermissionError(new Error('boom'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,16 @@ export async function fetchFortnoxFinancialYears(
|
||||
return years;
|
||||
}
|
||||
|
||||
/** Fetch and deduplicate voucher file connections for each financial year. */
|
||||
/**
|
||||
* Fetch and deduplicate voucher file connections for each financial year.
|
||||
*
|
||||
* Fortnox is asked per financial year first. If it answers 400 to the
|
||||
* `financialyear` filter (seen live 2026-08-20: the resource took the filter
|
||||
* in the docs' POST example but rejected the list call), one unfiltered
|
||||
* listing is fetched instead and rows are selected on their own VoucherYear.
|
||||
* A 400 that is really a missing scope/licence surfaces again on the
|
||||
* unfiltered call and propagates to the caller's permission handling.
|
||||
*/
|
||||
export async function fetchFortnoxFileConnections(
|
||||
client: FortnoxClient,
|
||||
accessToken: string,
|
||||
@@ -61,21 +70,16 @@ export async function fetchFortnoxFileConnections(
|
||||
): Promise<FortnoxFileConnection[]> {
|
||||
const connections: FortnoxFileConnection[] = [];
|
||||
const seen = new Set<string>();
|
||||
const wantedYears = new Set(financialYearIds);
|
||||
|
||||
for (const financialYearId of new Set(financialYearIds)) {
|
||||
const rawConnections = await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
`/voucherfileconnections?financialyear=${financialYearId}`,
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
);
|
||||
|
||||
const collect = (rawConnections: Record<string, unknown>[]) => {
|
||||
for (const raw of rawConnections) {
|
||||
const fileId = typeof raw.FileId === 'string' ? raw.FileId.trim() : '';
|
||||
const series = typeof raw.VoucherSeries === 'string' ? raw.VoucherSeries.trim() : '';
|
||||
const number = finiteInteger(raw.VoucherNumber);
|
||||
const itemFinancialYearId = finiteInteger(raw.VoucherYear);
|
||||
if (!fileId || !series || number == null || itemFinancialYearId == null) continue;
|
||||
if (!wantedYears.has(itemFinancialYearId)) continue;
|
||||
|
||||
const key = `${fileId}|${itemFinancialYearId}|${series}|${number}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -90,6 +94,30 @@ export async function fetchFortnoxFileConnections(
|
||||
financialYearId: itemFinancialYearId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const financialYearId of wantedYears) {
|
||||
let rawConnections: Record<string, unknown>[];
|
||||
try {
|
||||
rawConnections = await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
`/voucherfileconnections?financialyear=${financialYearId}`,
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof FortnoxApiError) || error.statusCode !== 400) throw error;
|
||||
collect(
|
||||
await client.getPaginated<Record<string, unknown>>(
|
||||
accessToken,
|
||||
'/voucherfileconnections',
|
||||
'VoucherFileConnections',
|
||||
{ pageSize: PAGE_SIZE },
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
collect(rawConnections);
|
||||
}
|
||||
|
||||
return connections;
|
||||
|
||||
@@ -17,6 +17,40 @@ export class FortnoxApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable message from a Fortnox error body
|
||||
* ({"ErrorInformation":{"error":1,"message":"...","code":2000423}}), or null
|
||||
* when the body is empty or not in that shape.
|
||||
*/
|
||||
export function fortnoxErrorMessage(error: unknown): string | null {
|
||||
if (!(error instanceof FortnoxApiError) || !error.body) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as {
|
||||
ErrorInformation?: { message?: unknown; Message?: unknown };
|
||||
message?: unknown;
|
||||
};
|
||||
const message =
|
||||
parsed?.ErrorInformation?.message ?? parsed?.ErrorInformation?.Message ?? parsed?.message;
|
||||
return typeof message === 'string' && message.trim() ? message.trim().slice(0, 300) : null;
|
||||
} catch {
|
||||
const text = error.body.trim();
|
||||
return text ? text.slice(0, 300) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing scope or licence. Fortnox documents 403 for failed authorisation,
|
||||
* but live answers for an unlicensed/unscoped resource have also come back
|
||||
* as 400 with a behörighet/scope/licens message, so both are recognised.
|
||||
*/
|
||||
export function isFortnoxPermissionError(error: unknown): error is FortnoxApiError {
|
||||
if (!(error instanceof FortnoxApiError)) return false;
|
||||
if (error.statusCode === 403) return true;
|
||||
if (error.statusCode !== 400) return false;
|
||||
const text = `${error.body ?? ''} ${fortnoxErrorMessage(error) ?? ''}`;
|
||||
return /beh[öo]righet|scope|licens|licence|license|permission|unauthori[sz]ed/i.test(text);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (isTimeoutError(error)) return true;
|
||||
if (error instanceof FortnoxApiError) {
|
||||
|
||||
Reference in New Issue
Block a user