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:
Jakob Wennberg
2026-08-20 13:25:06 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 5a8dd21931
commit e733ab7c43
20 changed files with 418 additions and 45 deletions
@@ -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);
});
});
+37 -9
View File
@@ -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;
+34
View File
@@ -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) {