feat(invoices): implement öresavrundning logic and next invoice numbe… (#429)

* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES
This commit is contained in:
Mattsson
2026-05-10 14:21:26 +02:00
committed by GitHub
parent c110d3ef99
commit 0ee5219b6c
19 changed files with 733 additions and 106 deletions
@@ -35,21 +35,48 @@ beforeEach(() => {
vi.restoreAllMocks()
})
function mockFetchStatus(status: number, body = '') {
function mockFetchStatus(status: number, body = '', headers?: HeadersInit) {
global.fetch = vi.fn(async () =>
new Response(body, { status, statusText: String(status) })
new Response(body, { status, statusText: String(status), headers })
) as unknown as typeof fetch
}
describe('skvRequest — error mapping', () => {
it('maps 401 → SESSION_EXPIRED', async () => {
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
mockFetchStatus(401)
await expect(
skvRequest(fakeSupabase, 'user-1', 'GET', '/x'),
).rejects.toMatchObject({
name: 'SkatteverketAuthError',
code: 'SESSION_EXPIRED',
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
expect((e as SkatteverketAuthError).message).toMatch(/Utvecklarportalen|prenumeration/i)
}
})
it('maps 401 with body text → SESSION_EXPIRED and includes body', async () => {
mockFetchStatus(401, 'token expired')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
expect((e as SkatteverketAuthError).message).toContain('token expired')
}
})
it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => {
mockFetchStatus(401, '', {
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
@@ -213,9 +213,55 @@ export async function skvRequest(
// 1. Genuine token expiry / invalid bearer (user must re-auth)
// 2. APIGW client lacks subscription for this API (developer portal fix)
// — the bearer is valid but the gateway rejects the call.
// Read the body so we can distinguish and surface a useful message.
// Read the body and gateway-side headers so we can distinguish and
// surface a useful message.
const text = await response.text().catch(() => '')
console.error('[skatteverket] 401 from API', { url, body: text })
// WWW-Authenticate carries OAuth's machine-readable failure reason
// (insufficient_scope / invalid_token). The x-skv-* / x-amzn-* / x-api-*
// families are gateway-side hints SKV's APIGW emits when it rejects the
// call before reaching the application — the body is often empty in
// that case so the headers are the only signal.
const wwwAuth = response.headers.get('WWW-Authenticate') ?? ''
const skvHeaders: Record<string, string> = {}
response.headers.forEach((v, k) => {
const lk = k.toLowerCase()
if (
lk === 'www-authenticate' ||
lk.startsWith('x-skv-') ||
lk.startsWith('x-amzn-') ||
lk.startsWith('x-api-')
) {
skvHeaders[k] = v
}
})
console.error('[skatteverket] 401 from API', { url, body: text, headers: skvHeaders })
// (A) Surface SKV's WWW-Authenticate verbatim — when the body is empty
// this header is usually the only diagnostic SKV gives us. Carry both
// header and body into every thrown message below.
const headerSuffix = Object.keys(skvHeaders).length > 0
? ` Headers: ${JSON.stringify(skvHeaders)}`
: ''
const bodySuffix = text ? ` Svar: ${text}` : ''
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
// as 401 (rather than 403) when the AGI APIGW evaluates scope before
// the application sees the token. The remedy is the same as MISSING_SCOPE:
// disconnect + reconnect to mint a token covering the AGI scope.
const wwwLower = wwwAuth.toLowerCase()
if (
wwwLower.includes('insufficient_scope') ||
wwwLower.includes('invalid_scope')
) {
throw new SkatteverketAuthError(
'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' +
'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' +
'för att förnya tokenen med rätt scope.' +
headerSuffix + bodySuffix,
'MISSING_SCOPE'
)
}
// APIGW subscription / client-credential problems: the gateway responds
// before the bearer is ever evaluated. The user reconnecting won't help
@@ -233,15 +279,47 @@ export async function skvRequest(
throw new SkatteverketAuthError(
'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' +
'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' +
`denna tjänst i Utvecklarportalen. Svar från Skatteverket: ${text || '(tomt svar)'}`,
'denna tjänst i Utvecklarportalen.' +
headerSuffix +
` Svar från Skatteverket: ${text || '(tomt svar)'}`,
'ACCESS_DENIED'
)
}
// (B) Empty 401 with no diagnostic header → almost always a gateway/
// subscription issue rather than a real session expiry. We refreshed
// the local bearer immediately above, so an empty body with no
// WWW-Authenticate means SKV's APIGW rejected the call before it
// reached the application — typically because the APIGW client isn't
// subscribed to the API at the URL we just hit. Telling the user to
// "log in again" sends them down a dead end; be explicit about the
// likely fix instead.
if (!text) {
// Extract the API segment of the URL so the message tells the user
// exactly which subscription is missing. Falls back to the raw URL
// if parsing fails.
let apiHint = url
try {
const u = new URL(url)
const parts = u.pathname.split('/').filter(Boolean)
// Take the first 3 segments — e.g. arbetsgivardeklaration/inlamning/v1
if (parts.length >= 1) apiHint = parts.slice(0, 3).join('/')
} catch {
// keep raw url
}
throw new SkatteverketAuthError(
'Skatteverkets API-gateway nekade anropet utan motivering. ' +
'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' +
`inte prenumeration på tjänsten "${apiHint}" i Utvecklarportalen, ` +
'eller den lagrade tokenen saknar rätt scope. Kontrollera ' +
'prenumerationen, koppla annars bort och anslut igen via ' +
'Inställningar → Skatteverket.' + headerSuffix,
'ACCESS_DENIED'
)
}
throw new SkatteverketAuthError(
text
? `Sessionen har gått ut. Logga in med BankID igen. (Skatteverket: ${text})`
: 'Sessionen har gått ut. Logga in med BankID igen.',
`Sessionen har gått ut. Logga in med BankID igen.${headerSuffix}${bodySuffix}`,
'SESSION_EXPIRED'
)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1
// section 4.1.2.2 — the 403 "Felaktigt access scope" example shows
// `"description": "The required scope agd has been requested for that access token."`
// The other tokens match the path segments of their respective APIs.
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto agd'
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skattekonto agd'
function getOAuthBaseUrl(): string {
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL