diff --git a/DECISIONS.md b/DECISIONS.md index 8066211c..862eb732 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -946,8 +946,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. +[2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step. +[2026-08-13] Reversed the #1250 rule that the APIGW "required scopes are not authorized" body must name only the subscription: production proved the same body also fires when the TOKEN lacks the scope (AGI kvittens needed agdredovisningperiod), so naming one cause and hiding the other presents a coin flip as a diagnosis. The message now names both knobs plus the refused API path, and system mode points at SKATTEVERKET_SYSTEM_SCOPES again. Classification is unchanged (ACCESS_DENIED, still not a reconsent code): a reconnect only helps once the scope exists, so it can never be automatic. [2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue. [2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail. +[2026-08-13] The AGIPanel kvittens-scope notice is dismissible (persisted per granted scope string), unlike the hard agd banner: all existing hosted tokens lack agdredovisningperiod, and until Skatteverket's application registration carries that scope a reconnect mints the same grant again (SKV silently drops unregistered scope names), so a hard reconnect demand would be the #1010 un-clearable banner, each futile attempt costing a BankID round-trip via disconnect. Keying dismissal by the exact grant keeps a futile reconnect quiet while a widened grant re-evaluates from scratch. [2026-08-13] Web picker no longer advertises HEIC/HEIF (accept lists in InvoiceInboxWorkspace): supersedes the 2026-08-01 HEIC decision (runtime sharp transcode + hint). The transcode+hint stays as fallback for drag-drop/API/channel HEIC, but without heic in accept iOS transcodes photo-library picks to JPEG, which makes AI extraction work and shrinks 413 risk; magic-byte validation now covers real HEIC/HEIF via ftyp brands instead of a blanket exemption. [2026-08-13] Skipped the planned lib/browser/downscale-image.ts module: lib/documents/shrink-image.ts + upload-size.ts already shipped the client-side downscale wired into the inbox uploadFile (hosted 4 MB ceiling, 2400px long edge, honest refusal message); a second overlapping compressor with different thresholds would create contradictory policies. [2026-08-13] WhatsApp M1 greeting: media from unlinked senders bypasses the 1-hour throttle behind a 10-minute burst window (3/day cap kept): a photo is a receipt someone expects to be handled, and pure hour-long silence taught senders their receipts were being filed; one burst still earns exactly one reply. In-channel silence after stopp stays absolute (only the literal start is read): stopp is an opt-out and any other reply would violate it, so the escape hatch is app-side instead (POST /link/unmute + panel button), where the user is already authenticated and asking. diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 20d81302..3ebb3746 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -165,6 +165,36 @@ export function AGIPanel(props: AGIPanelProps) { // /authorize call overwrites the stored oauth_state + PKCE verifier, so a // parallel flow guarantees a CSRF failure for whichever tab finishes last. const [connecting, setConnecting] = useState(false) + // Dismissal for the kvittens-scope notice, keyed by employer plus the exact + // granted scope string. The employer keeps dismissals from leaking across + // companies on a shared browser (tokens are per company, so each company's + // grant is its own question); the scope string means a reconnect that comes + // back with the same grant (the scope not yet registered on Skatteverket's + // application) keeps the dismissal, so the notice cannot become an + // un-clearable reconnect loop (#1010), while a new grant re-evaluates from + // scratch. + const [kvittensNoticeDismissed, setKvittensNoticeDismissed] = useState(false) + const grantedScopeString = typeof status?.scope === 'string' ? status.scope : null + const kvittensNoticeKey = grantedScopeString + ? `agi-kvittens-scope-notice:${arbetsgivare}:${grantedScopeString}` + : null + useEffect(() => { + if (!kvittensNoticeKey) return + try { + setKvittensNoticeDismissed(localStorage.getItem(kvittensNoticeKey) === 'dismissed') + } catch { + setKvittensNoticeDismissed(false) + } + }, [kvittensNoticeKey]) + const dismissKvittensNotice = useCallback(() => { + setKvittensNoticeDismissed(true) + if (!kvittensNoticeKey) return + try { + localStorage.setItem(kvittensNoticeKey, 'dismissed') + } catch { + // Best effort: the state update alone hides it for this mount. + } + }, [kvittensNoticeKey]) // "2026-06" for user-facing copy; the period prop is compact YYYYMM. const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}` @@ -884,12 +914,21 @@ export function AGIPanel(props: AGIPanelProps) { ) } - // Tokens issued before the agd scope was added to DEFAULT_SCOPES will - // 403 with invalid_scope at submission time: surface that proactively - // so the user reconnects before hitting the deadline rather than at it. - const missingAgdScope = - typeof status?.scope === 'string' && - !status.scope.split(/\s+/).filter(Boolean).includes('agd') + // Tokens issued before an AGI scope was added to DEFAULT_SCOPES will 403 at + // submission time: surface that proactively so the user reconnects before + // hitting the deadline rather than at it. The two scopes back different + // steps and get different treatments: `agd` (inlämning) fails already at + // submit, is proven grantable, and keeps the hard reconnect nudge. A token + // missing only `agdredovisningperiod` (hantera) sails through submit and + // signing and dies on "Hämta kvittens", but until Skatteverket's application + // registration carries that scope a reconnect mints the same grant again + // (SKV silently drops unregistered scope names), so its notice must be + // dismissible rather than a demand no reconnect can clear (#1010). + const grantedScopes = + typeof status?.scope === 'string' ? status.scope.split(/\s+/).filter(Boolean) : null + const missingAgdScope = grantedScopes !== null && !grantedScopes.includes('agd') + const missingKvittensScope = + grantedScopes !== null && !missingAgdScope && !grantedScopes.includes('agdredovisningperiod') // Recovery states expose the advanced actions on their own: the stale-draft // and error-report guidance below reference them by name. @@ -991,10 +1030,12 @@ export function AGIPanel(props: AGIPanelProps) { )} - {/* Missing-scope banner: proactive nudge before the user hits a - 403 invalid_scope at submission time. The agd scope was added - after some users had already connected, so their stored token - grants moms/skattekonto but not AGI. */} + {/* Missing-scope nudges: proactive, before the user hits a 403 + invalid_scope. The agd scope was added after some users had + already connected, so their stored token grants moms/skattekonto + but not AGI: that one stays a hard nudge. The kvittens scope only + breaks the final receipt fetch and may not be grantable yet, so + its notice is softer and dismissible. */} {missingAgdScope && !readOnly && (

@@ -1011,6 +1052,31 @@ export function AGIPanel(props: AGIPanelProps) {

)} + {missingKvittensScope && !kvittensNoticeDismissed && !readOnly && ( +
+

+ {t('kvittens_scope_title')} +

+

+ {t('kvittens_scope_description')} +

+
+ + {t('open_settings')} + + +
+
+ )} {/* Status summary */}
diff --git a/extensions/general/skatteverket/__tests__/api-client.test.ts b/extensions/general/skatteverket/__tests__/api-client.test.ts index cc27052b..373db3d3 100644 --- a/extensions/general/skatteverket/__tests__/api-client.test.ts +++ b/extensions/general/skatteverket/__tests__/api-client.test.ts @@ -126,6 +126,31 @@ describe('skvRequest: error mapping', () => { } }) + // The same body has two causes (subscription gap #973, missing token scope + // like the AGI kvittens one), and the gateway never says which. A message + // naming only the subscription sent a real prod filing down a dead end: the + // fix was a scope, so every minute spent in Utvecklarportalen was wasted. + it('names BOTH causes and the refused service on the gateway 403', async () => { + mockFetchStatus(403, '{"error": "The required scopes are not authorized"}') + try { + // The real shape of the incident: the kvittens read on the hantera API. + await skvRequest( + fakeSupabase, 'user-1', 'comp-1', 'GET', + '/arbetsgivare/165560000000/redovisningsperioder/202608/kvittenser', + undefined, + { baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1' }, + ) + expect.fail('expected throw') + } catch (e) { + const { message } = e as SkatteverketAuthError + expect(message).toMatch(/prenumeration/) + expect(message).toMatch(/scope/) + // Which service refused. Naming it is the whole point: the sibling + // inlamning API kept working, so "Skatteverket said no" is not a clue. + expect(message).toContain('arbetsgivardeklaration/hanteraredovisningsperiod/v1') + } + }) + it('still maps a real token-scope rejection → MISSING_SCOPE', async () => { // Body shape from SKV's AGI Tjänstebeskrivning v1.7 §4.1.2.2. mockFetchStatus( @@ -158,10 +183,19 @@ describe('skvRequest: error mapping', () => { 'WWW-Authenticate': 'Bearer error="invalid_scope"', }) try { - await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x') + await skvRequest( + fakeSupabase, 'user-1', 'comp-1', 'GET', '/x', undefined, + { baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1' }, + ) expect.fail('expected throw') } catch (e) { - expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED') + const { code, message } = e as SkatteverketAuthError + expect(code).toBe('ACCESS_DENIED') + // Same shared message as the 403 contract path: both causes named plus + // the refused service, so the 401 shape cannot drift into vaguer text. + expect(message).toMatch(/prenumeration/) + expect(message).toMatch(/scope/) + expect(message).toContain('arbetsgivardeklaration/inlamning/v1') } }) @@ -274,10 +308,12 @@ describe('skvRequestWithAuth: system mode', () => { } }) - it('403 APIGW contract error in system mode names the subscription, not the scope list', async () => { - // Still SYSTEM_AUTH_FAILED (run-level config either way), but the two are - // fixed with different knobs, so the message must not send the operator to - // SKATTEVERKET_SYSTEM_SCOPES when the gateway is what refused. + it('403 APIGW contract error in system mode names both knobs', async () => { + // Still SYSTEM_AUTH_FAILED (run-level config either way). This assertion + // is the inverse of what it was: #1250 removed the SKATTEVERKET_SYSTEM_SCOPES + // mention on the reasoning that the gateway, not the scope list, had + // refused. Production later proved the body cannot distinguish the two, so + // naming one and hiding the other is a coin flip presented as a diagnosis. mockFetchStatus(403, '{"error": "The required scopes are not authorized"}') try { await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x') @@ -285,7 +321,7 @@ describe('skvRequestWithAuth: system mode', () => { } catch (e) { expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED') expect((e as SkatteverketAuthError).message).toMatch(/prenumeration/) - expect((e as SkatteverketAuthError).message).not.toMatch(/SKATTEVERKET_SYSTEM_SCOPES/) + expect((e as SkatteverketAuthError).message).toMatch(/SKATTEVERKET_SYSTEM_SCOPES/) } }) diff --git a/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts b/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts new file mode 100644 index 00000000..5f9a2d1d --- /dev/null +++ b/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { buildAuthorizeUrl } from '../lib/oauth' + +/** + * Scope-set regression guard. + * + * Every entry here was added because removing it broke a live filing, and the + * damage is always delayed: SKV issues the token happily and the missing scope + * only surfaces as a 403 from the one API that needed it. A "remove unused + * scopes" cleanup has already cost us this twice (#431 for `ska`, and `agd` + * alone for AGI kvittenser). Pin them. + */ +describe('Skatteverket per-flow OAuth scopes', () => { + const originalClientId = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID + + beforeEach(() => { + process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = 'test-client-id' + }) + + afterEach(() => { + if (originalClientId === undefined) delete process.env.SKATTEVERKET_OAUTH2_CLIENT_ID + else process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = originalClientId + }) + + function requestedScopes(): string[] { + const url = new URL(buildAuthorizeUrl('https://app.example/callback', 'state-123')) + return (url.searchParams.get('scope') ?? '').split(' ').filter(Boolean) + } + + it('requests both AGI scopes, one per backing API', () => { + // `agd` backs inlamning (submit/sign); `agdredovisningperiod` backs + // hanteraredovisningsperiod (kvittenser/las). A token with only the first + // completes a filing and then fails on "Hämta kvittens". + expect(requestedScopes()).toEqual( + expect.arrayContaining(['agd', 'agdredovisningperiod']), + ) + }) + + it('keeps the interactive skattekonto scope', () => { + expect(requestedScopes()).toContain('ska') + }) + + it('keeps the momsdeklaration scope', () => { + expect(requestedScopes()).toContain('momsdeklaration') + }) + + it('lets an explicit scope option override the defaults', () => { + const url = new URL( + buildAuthorizeUrl('https://app.example/callback', 'state-123', { scope: 'agd' }), + ) + expect(url.searchParams.get('scope')).toBe('agd') + }) +}) diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 96e879bb..3e162464 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -220,25 +220,75 @@ async function refreshTokenForUser( } /** - * MuleSoft APIGW contract enforcement, observed verbatim in production: + * MuleSoft APIGW scope enforcement, observed verbatim in production: * * { "error": "The required scopes are not authorized" } * - * The gateway emits this when OUR APIGW client (SKATTEVERKET_APIGW_CLIENT_ID) - * has no subscription for the API being called (#973). It is decided before - * the bearer is ever evaluated, so it says nothing about the user's token. + * TWO different misconfigurations produce this one body, and they are fixed + * with different knobs: * - * It has to be ruled out explicitly because it contains the substring - * "required scope", which is how the SKV token-scope rejection used to be - * detected: that collision classified every gateway 403 as MISSING_SCOPE, and - * MISSING_SCOPE is in RECONSENT_ERROR_CODES, so a successful reconnect - * (runPostConnectRefresh -> syncSkattekonto -> 403) instantly re-flagged the - * token row and the reconnect banner perpetuated itself (#1155). + * 1. Our APIGW client (SKATTEVERKET_APIGW_CLIENT_ID) has no subscription for + * the API being called (#973). Fixed in Utvecklarportalen. + * 2. The token is missing the scope that API requires, because the SKV + * application was never registered for it, or because we never asked for + * it. AGI needs `agd` for inlamning AND `agdredovisningperiod` for + * hanteraredovisningsperiod; a token holding only the first files and + * signs perfectly, then dies on the kvittens read. `ska` behaved the same + * way for skattekonto (#431). Fixed by registering/requesting the scope + * and reconnecting. + * + * The gateway will not tell us which, so neither can we: the message names + * both, and callers still classify it as ACCESS_DENIED. That verdict is about + * blast radius, not about cause. ACCESS_DENIED is deliberately NOT in + * RECONSENT_ERROR_CODES: guessing "reconnect" is what made a successful + * reconnect (runPostConnectRefresh -> syncSkattekonto -> 403) instantly + * re-flag the token row, so the banner perpetuated itself (#1155). Case 2 does + * need a reconnect, but only AFTER the scope exists, so an automatic reconsent + * loop would still be wrong. + * + * It also has to be ruled out before isTokenScopeRejection, which matches the + * substring "required scope". + * + * To tell the two apart, call the API with a deliberately invalid bearer and + * the same Client_Id/Client_Secret. Case 1 fails at the gateway with this same + * body; case 2 reaches the bearer check and answers 401 invalid/revoked token. + * Compare against an API the client is known to be subscribed to. */ function isApigwScopeContractError(body: string): boolean { return /required scopes?\s+are\s+not\s+authorized/i.test(body) } +/** + * The API segment of a SKV URL ("arbetsgivardeklaration/inlamning/v1"), for + * error messages that have to say WHICH service refused. Without it the user + * cannot tell which subscription or scope to go check, which is exactly the + * dead end the 403 message used to leave them in. + */ +function apiHintFromUrl(url: string): string { + try { + const parts = new URL(url).pathname.split('/').filter(Boolean) + return parts.length >= 1 ? parts.slice(0, 3).join('/') : url + } catch { + return url + } +} + +/** + * User-facing message for the ambiguous gateway/scope refusal. Single-sourced + * so the 401 and 403 paths cannot drift apart. + */ +function apigwOrScopeMessage(url: string): string { + return ( + `Skatteverket nekade anropet till tjänsten "${apiHintFromUrl(url)}". ` + + 'Två saker ger samma svar: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) ' + + 'saknar prenumeration på tjänsten, eller så saknar anslutningen det scope ' + + 'tjänsten kräver. Kontrollera båda i Utvecklarportalen: prenumerationen på ' + + 'API:et, och att applikationens scope-lista täcker det. Om ett scope har ' + + 'lagts till behöver du koppla bort och ansluta igen via Inställningar → ' + + 'Skatteverket för att få en ny token.' + ) +} + /** * A genuine token-scope rejection: the stored access token predates a scope * the service now requires, and only a fresh consent can widen it. @@ -405,12 +455,7 @@ export async function skvRequestWithAuth( // cannot fix: SESSION_EXPIRED and MISSING_SCOPE are both reconsent codes, // so either verdict re-arms the banner the user just tried to clear. if (isApigwScopeContractError(text)) { - 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.', - 'ACCESS_DENIED' - ) + throw new SkatteverketAuthError(apigwOrScopeMessage(url), 'ACCESS_DENIED') } // OAuth's standard insufficient_scope marker. SKV sometimes emits this @@ -466,10 +511,13 @@ export async function skvRequestWithAuth( lower.includes('api key') || lower.includes('consumer') if (looksLikeApigwIssue) { + // Named the subscription outright: unlike the scope-contract body above, + // these shapes (client_id, consumer, subscription) point at the gateway + // client alone, so the message must not muddy it with the scope story. 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.', + `Skatteverkets API-gateway nekade anropet till "${apiHintFromUrl(url)}". ` + + 'Kontrollera att din APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har ' + + 'prenumeration på denna tjänst i Utvecklarportalen.', 'ACCESS_DENIED' ) } @@ -483,18 +531,7 @@ export async function skvRequestWithAuth( // "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 - } + const apiHint = apiHintFromUrl(url) throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet utan motivering. ' + 'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' + @@ -529,9 +566,10 @@ export async function skvRequestWithAuth( // point at the scope list when the gateway is what refused. if (isApigwScopeContractError(text)) { throw new SkatteverketAuthError( - 'Skatteverkets API-gateway nekade systemanropet: APIGW-klienten ' + - '(SKATTEVERKET_APIGW_CLIENT_ID) saknar prenumeration på denna ' + - 'tjänst i Utvecklarportalen.', + 'Skatteverket nekade systemanropet till tjänsten ' + + `"${apiHintFromUrl(url)}": antingen saknar APIGW-klienten ` + + '(SKATTEVERKET_APIGW_CLIENT_ID) prenumeration på tjänsten, eller så ' + + 'täcker inte SKATTEVERKET_SYSTEM_SCOPES det scope tjänsten kräver.', 'SYSTEM_AUTH_FAILED' ) } @@ -552,17 +590,13 @@ export async function skvRequestWithAuth( 'OMBUD_GRANT_MISSING' ) } - // Gateway contract failure, checked first: it wears scope wording but is - // our APIGW subscription, not the user's token. Reconnecting cannot fix - // it, and calling it MISSING_SCOPE made every reconnect re-flag the row - // (#1155). ACCESS_DENIED is deliberately not in RECONSENT_ERROR_CODES. + // Gateway scope enforcement, checked first: the body wears token-scope + // wording but names neither cause, so it must not reach + // isTokenScopeRejection below. ACCESS_DENIED is deliberately not in + // RECONSENT_ERROR_CODES (#1155): a reconnect is only the fix once the + // scope actually exists, so it can never be automatic. if (isApigwScopeContractError(text)) { - 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.', - 'ACCESS_DENIED' - ) + throw new SkatteverketAuthError(apigwOrScopeMessage(url), 'ACCESS_DENIED') } // Missing scope on the access token: fires when an existing connection // pre-dates an extension that needed a new scope (the AGI/`agd` rollout diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts index dc53cebe..56c56a98 100644 --- a/extensions/general/skatteverket/lib/oauth.ts +++ b/extensions/general/skatteverket/lib/oauth.ts @@ -38,7 +38,25 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1 // - `skattekonto` is NOT a real SKV scope name: SKV silently drops it // from every grant. Kept only so a future SKV rename in // our favor costs nothing. -const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto ska agd' +// +// AGI needs TWO scopes, one per backing API, and missing the second one is +// invisible until the very last step of a filing: +// - `agd` = arbetsgivardeklaration/inlamning (POST underlag, +// kontrollresultat, spara, skapaGranskningsunderlag). +// - `agdredovisningperiod` = arbetsgivardeklaration/hanteraredovisningsperiod +// (kvittenser, las, lasUpp). Note the spelling: +// "redovisningperiod", no genitive s, exactly as +// SKV registers it. Added 2026-08-13 after a real +// prod filing signed fine and then failed on +// "Hämta kvittens" with 403 {"error": "The +// required scopes are not authorized"}: the token +// carried `agd` only, so the hantera API refused +// while inlämning kept working. Same body as the +// APIGW subscription gap (#973), which is why the +// gateway/token distinction has to be made with +// the APIGW client, not from this string alone. +const DEFAULT_SCOPES = + 'momsdeklaration inkforetag skahmst skattekonto ska agd agdredovisningperiod' function getOAuthBaseUrl(): string { return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL diff --git a/messages/en.json b/messages/en.json index 0e55f74c..1ba00de0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6516,6 +6516,9 @@ "expired_banner_description": "Sign in with BankID again to be able to submit AGI.", "missing_scope_title": "The Skatteverket connection lacks permission for Arbetsgivardeklaration", "missing_scope_description": "Your connection was issued before AGI support was enabled. Disconnect and reconnect via Settings → Tax to be able to submit AGI directly.", + "kvittens_scope_title": "The receipt needs a new permission", + "kvittens_scope_description": "Your connection can submit and sign AGI, but lacks the permission (agdredovisningperiod) required to fetch the receipt automatically. Disconnect and reconnect via Settings → Tax to add it. If the permission does not yet appear on Skatteverket's consent page, you can hide this notice and reconnect later; the submission itself is unaffected, and the receipt for a submitted period can always be verified in Skatteverket's Arbetsgivardeklaration e-service.", + "kvittens_scope_dismiss": "Hide notice", "open_settings": "Open settings", "file_generated": "AGI file generated {date}", "file_not_generated": "The AGI file has not been generated yet.", diff --git a/messages/sv.json b/messages/sv.json index af08db1a..6338d755 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6516,6 +6516,9 @@ "expired_banner_description": "Logga in med BankID igen för att kunna skicka AGI.", "missing_scope_title": "Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration", "missing_scope_description": "Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla bort och anslut igen via Inställningar → Skatt för att kunna skicka AGI direkt.", + "kvittens_scope_title": "Kvittensen behöver en ny behörighet", + "kvittens_scope_description": "Din anslutning kan lämna in och signera AGI, men saknar behörigheten (agdredovisningperiod) som krävs för att hämta kvittensen automatiskt. Koppla bort och anslut igen via Inställningar → Skatt för att lägga till den. Syns behörigheten inte på Skatteverkets samtyckessida ännu kan du dölja notisen och ansluta igen senare; själva inlämningen påverkas inte, och kvittensen för en inlämnad period kan alltid kontrolleras i Skatteverkets e-tjänst Arbetsgivardeklaration.", + "kvittens_scope_dismiss": "Dölj notisen", "open_settings": "Öppna inställningar", "file_generated": "AGI-fil genererad {date}", "file_not_generated": "AGI-fil har inte genererats ännu.",