* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import { FORTNOX_AUTH_URL, FORTNOX_TOKEN_URL } from './config';
|
|
import type { OAuthConfig, TokenResponse } from '../types';
|
|
import {
|
|
fetchWithTimeout,
|
|
OAUTH_TIMEOUT_MS,
|
|
} from '@/lib/http/fetch-with-timeout';
|
|
|
|
const BASE_SCOPES = [
|
|
'companyinformation',
|
|
'invoice',
|
|
'supplierinvoice',
|
|
'customer',
|
|
'supplier',
|
|
'bookkeeping',
|
|
];
|
|
|
|
/** Arkivplats + Koppla filer: what the voucher attachment import reads. */
|
|
export const FORTNOX_DOCUMENT_SCOPES = ['archive', 'connectfile'];
|
|
|
|
/**
|
|
* Scope-approval flags describe the FORTNOX APP REGISTRATION, not the code:
|
|
* whether the app in the Fortnox Developer Portal carries a given scope.
|
|
* Requesting a scope the registration lacks makes the authorize endpoint
|
|
* reject with invalid_scope BEFORE login (prod incident 2026-08-13, when the
|
|
* ordinary connect carried unapproved scopes and every Fortnox connection
|
|
* died), and claiming a scope the connect never asks for sends users into a
|
|
* reconnect loop that cannot succeed (support case Klura AB, 2026-08-20).
|
|
*
|
|
* The defaults below describe the hosted deployment's registration
|
|
* (integration 39254). A self-hosted deployment runs its OWN Fortnox app
|
|
* (FORTNOX_CLIENT_ID in .env), whose registration will differ, so each flag
|
|
* can be overridden with an env var of the same name: "true" or "false",
|
|
* unset means the hosted default. Without the override, self-hosters whose
|
|
* registration differs from hosted's would have to patch this file.
|
|
*/
|
|
export function fortnoxScopeFlag(
|
|
envValue: string | undefined,
|
|
hostedDefault: boolean,
|
|
): boolean {
|
|
// Trimmed: a stray space in a hand-edited .env line must not silently
|
|
// flip a scope off and read as a missing feature.
|
|
const value = envValue?.trim();
|
|
if (value === undefined || value === '') return hostedDefault;
|
|
return value === 'true';
|
|
}
|
|
|
|
/**
|
|
* Whether the registered Fortnox app has Arkivplats and Koppla filer enabled.
|
|
* Hosted default true since 2026-08-21, when the portal registration was
|
|
* confirmed to carry both; set the env var to false the moment a registration
|
|
* loses them, rather than leaving the underlag reconnect pointed at a scope
|
|
* Fortnox will refuse.
|
|
*
|
|
* It gates the opt-in document consent below and the document-import error
|
|
* message, never the ordinary connect: a user is never told to reconnect for a
|
|
* permission we don't ask for.
|
|
*/
|
|
export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = fortnoxScopeFlag(
|
|
process.env.FORTNOX_DOCUMENT_SCOPES_APPROVED,
|
|
true,
|
|
);
|
|
|
|
/** The asset register (anläggningsregistret): what the asset import reads. */
|
|
export const FORTNOX_ASSET_SCOPES = ['assets'];
|
|
|
|
/**
|
|
* Whether the registered Fortnox app has the Assets scope (Anläggningsregister)
|
|
* enabled. Hosted default false until the portal registration is confirmed to
|
|
* carry it.
|
|
*
|
|
* When true, the ordinary connect requests the scope. Unlike Arkivplats and
|
|
* Koppla filer, the asset register carries no separate Fortnox customer
|
|
* licence, so no per-user opt-in is needed. A consent minted without the
|
|
* scope degrades gracefully: the migration reports assets as skipped instead
|
|
* of failing (see arcim-migration import-assets).
|
|
*/
|
|
export const FORTNOX_ASSET_SCOPES_APPROVED: boolean = fortnoxScopeFlag(
|
|
process.env.FORTNOX_ASSET_SCOPES_APPROVED,
|
|
false,
|
|
);
|
|
|
|
/**
|
|
* The scopes a Fortnox consent is minted with. The document scopes are opt-in
|
|
* per authorize call, because Fortnox derives its customer licence
|
|
* requirements from what an integration requests: a customer who never imports
|
|
* receipts should not be asked to hold an Arkivplats licence to connect at all.
|
|
*
|
|
* The base scopes always ride along. The OAuth callback overwrites the
|
|
* consent's tokens in place, so a document consent minted from the two extra
|
|
* scopes alone would strip the migration's own access to the ledger.
|
|
*/
|
|
export function fortnoxConsentScopes(options?: { documents?: boolean }): string[] {
|
|
const withDocuments =
|
|
options?.documents === true && FORTNOX_DOCUMENT_SCOPES_APPROVED;
|
|
const scopes = withDocuments
|
|
? [...BASE_SCOPES, ...FORTNOX_DOCUMENT_SCOPES]
|
|
: [...BASE_SCOPES];
|
|
// The asset register rides along on every consent once the portal
|
|
// registration carries the scope: it needs no extra customer licence, so
|
|
// there is nothing to opt in to.
|
|
if (FORTNOX_ASSET_SCOPES_APPROVED) scopes.push(...FORTNOX_ASSET_SCOPES);
|
|
return scopes;
|
|
}
|
|
|
|
export function buildFortnoxAuthUrl(
|
|
config: OAuthConfig,
|
|
options?: { scopes?: string[]; state?: string },
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
client_id: config.clientId,
|
|
redirect_uri: config.redirectUri,
|
|
response_type: 'code',
|
|
access_type: 'offline',
|
|
});
|
|
|
|
const scopes = options?.scopes?.length
|
|
? options.scopes
|
|
: fortnoxConsentScopes();
|
|
params.set('scope', scopes.join(' '));
|
|
|
|
if (options?.state) {
|
|
params.set('state', options.state);
|
|
}
|
|
|
|
return `${FORTNOX_AUTH_URL}?${params.toString()}`;
|
|
}
|
|
|
|
function basicAuthHeader(config: OAuthConfig): string {
|
|
const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
|
|
return `Basic ${encoded}`;
|
|
}
|
|
|
|
export async function exchangeFortnoxCode(
|
|
config: OAuthConfig,
|
|
code: string,
|
|
): Promise<TokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
FORTNOX_TOKEN_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: basicAuthHeader(config),
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: config.redirectUri,
|
|
}).toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Fortnox token exchange' },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Fortnox token exchange failed: ${response.status} ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<TokenResponse>;
|
|
}
|
|
|
|
export async function refreshFortnoxToken(
|
|
config: OAuthConfig,
|
|
refreshToken: string,
|
|
): Promise<TokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
FORTNOX_TOKEN_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: basicAuthHeader(config),
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
}).toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Fortnox token refresh' },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Fortnox token refresh failed: ${response.status} ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<TokenResponse>;
|
|
}
|