fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals Skatteverket connections become per (user, company): the token table carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half migrations), so one connection leaked "connected" onto every company the user belongs to, sync ran the token against the wrong orgnr (behorighet 403), and reconnecting from another company silently moved the row and went dark on the first company's crons. Token reads/writes are now scoped by company through the whole chain (token-store, api-client refresh coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP), /skattekonto/saldo answers 401 NOT_CONNECTED for companies without their own row (which is what the page's startkort keys on), and the dashboard connect-nudge counts only the active company's row. Bookkeeping's pristine start card now keys on all-years emptiness via a count probe instead of "no active filters": the default fiscal-year selection counted as a filter, which made the card unreachable on brand-new companies (it showed "inga traffar" instead). Two browser-global localStorage keys become company-scoped with legacy fallbacks: the inbox onboarding dismissal (dismissing on one company hid the card everywhere) and the periodisering auto-detect toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scoping): dedupe cron work per (user, company) + guard the ledger probe CodeRabbit findings on #1610: the skattekonto sync cron still deduped token rows by user_id alone, which would drop every company but one for multi-company operators (the exact scenario the PR fixes); and the all-years ledger probe could leave a stale false behind on a failed refetch, letting the pristine card render unconfirmed. The probe now resets to unknown in flight and carries the fetch generation guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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
Claude Fable 5
Jakob Wennberg
parent
4e14182a00
commit
18c20e68e6
@@ -976,3 +976,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
|
||||
[2026-08-13] Renaming a migration after the Supabase preview applied it orphans the PREVIEW tracker, not prod: the preview branch for PR 1605 had recorded 20260813180000, the rename to 20260813213000 removed that file, and the next Migrations task aborted with "Remote migration versions not found in local migrations directory". Repair = delete the orphaned version row from supabase_migrations.schema_migrations on the preview branch project only (prod never applied it, so merge-time apply is unaffected), then re-run tasks with a fresh commit. The migration itself is drop-if-exists idempotent so the replay under the new version is safe.
|
||||
[2026-08-14] PR #1608 round-2 compliance finding (VMB class-3/4 VAT hole): account_override now books GROSS with no auto-VAT line unless the caller stated explicit VAT intent (vat_treatment or vat_amount). Deliberate divergence from v1 REST, which keeps the category-default standard_25 on overrides: MCP callers are agents, and a forgotten flag must under-deduct (lawful), never fabricate an ingående-moms deduction on a margin-scheme account. The review's class-appropriateness suggestion (block e.g. expense onto 27xx) was noted but not implemented: the approval gate is the guard, and a class matrix would block legitimate balance-sheet bookings v1 REST supports today.
|
||||
[2026-08-14] Skatteverket connections are per (user, company): the stacked UNIQUE(user_id) + UNIQUE(company_id) pair is replaced by UNIQUE(user_id, company_id), every token read/write is company-scoped, and /skattekonto/saldo returns 401 NOT_CONNECTED when the active company has no row. A multi-company operator connects each company separately; legacy NULL-company rows are ignored by scoped reads and just require a reconnect.
|
||||
[2026-08-14] Pristine empty-state gates key on all-years emptiness (a count probe), never on the scoped list result: a default fiscal-year selection is a scope, not a filter, and it made the bookkeeping start card unreachable on brand-new companies.
|
||||
|
||||
@@ -61,10 +61,10 @@ export default async function DashboardPage() {
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||
// Skatteverket tokens are user-scoped (one BankID identity per user) but
|
||||
// carry the active company_id; either filter would work: we use user_id
|
||||
// because that's what the token-store reads/writes against.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
||||
// Skatteverket connections are per (user, company): filtering on user_id
|
||||
// alone made a connection on ANY of the user's companies hide the connect
|
||||
// nudge on all of them.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
|
||||
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
||||
// marks the receipts checklist step done: same "has ever done X" shape
|
||||
// as the other flags above.
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('AGI kvittenser cron', () => {
|
||||
expect(body.expired).toBe(1)
|
||||
expect(body.apigwConfig).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'comp-1', 'SESSION_EXPIRED')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(errorRecorder).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function GET(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, companyId, err.code)
|
||||
}
|
||||
results.push({ declarationId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
|
||||
@@ -82,7 +82,9 @@ export async function GET(request: Request) {
|
||||
.order('expires_at', { ascending: true })
|
||||
.order('user_id', { ascending: true })
|
||||
.range(from, to),
|
||||
{ dedupeBy: token => token.user_id },
|
||||
// One row per (user, company) since tokens went per-company: deduping by
|
||||
// user alone would drop every company but one for multi-company operators.
|
||||
{ dedupeBy: token => `${token.user_id}:${token.company_id}` },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[skattekonto-sync-cron] Failed to fetch tokens', {
|
||||
@@ -213,7 +215,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
||||
const auth: SkvAuth =
|
||||
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId }
|
||||
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId, companyId }
|
||||
const syncResult = await syncSkattekonto(ctx, auth)
|
||||
|
||||
// Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits
|
||||
@@ -271,7 +273,7 @@ export async function GET(request: Request) {
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
await markNeedsReconsent(supabase, userId, companyId, err.code)
|
||||
results.push({ userId, companyId, source, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('VAT kvittenser cron', () => {
|
||||
|
||||
expect(body.expired).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'comp-1', 'SESSION_EXPIRED')
|
||||
})
|
||||
|
||||
it('records error for generic failures without aborting the run', async () => {
|
||||
|
||||
@@ -266,7 +266,7 @@ export async function GET(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, companyId, err.code)
|
||||
}
|
||||
} catch (reconsentErr) {
|
||||
console.warn('[vat-kvittenser-cron] Failed to persist reconsent flag', {
|
||||
|
||||
@@ -257,6 +257,12 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
|
||||
// original). Toggled off via the filter dialog to reveal the full chain.
|
||||
const [collapseCorrections, setCollapseCorrections] = useState(true)
|
||||
const [draftCount, setDraftCount] = useState(0)
|
||||
// All-years emptiness, resolved only when the scoped list comes back empty:
|
||||
// the pristine start card must key on "this ledger has never had an entry",
|
||||
// not "the selected fiscal year is empty" (the default year selection used
|
||||
// to make the pristine state unreachable on brand-new companies). null =
|
||||
// not yet known; the pristine gate requires an explicit false.
|
||||
const [ledgerHasAnyEntry, setLedgerHasAnyEntry] = useState<boolean | null>(null)
|
||||
const [pageSizeChoice, setPageSizeChoice] = useState<PageSizeChoice>('20')
|
||||
const [pageSizeHydrated, setPageSizeHydrated] = useState(false)
|
||||
const showingAll = pageSizeChoice === 'all'
|
||||
@@ -537,8 +543,15 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
|
||||
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
||||
// a stale count of 0. Every other case refreshes the badge in the background.
|
||||
if (loadedEntries.length === 0 && listMode === 'committed') {
|
||||
await fetchDraftCount()
|
||||
const unscopedQuery = !periodId && !dateFrom && !dateTo && seriesFilter === 'all' && !search
|
||||
await Promise.all([
|
||||
fetchDraftCount(),
|
||||
unscopedQuery
|
||||
? Promise.resolve(setLedgerHasAnyEntry((total || 0) > 0))
|
||||
: fetchLedgerHasAnyEntry(isCurrent),
|
||||
])
|
||||
} else {
|
||||
if (listMode === 'committed' && loadedEntries.length > 0) setLedgerHasAnyEntry(true)
|
||||
fetchDraftCount()
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
@@ -562,6 +575,24 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap count-only probe across ALL years and filters: does this ledger
|
||||
// hold any committed entry at all? Distinguishes "pristine ledger" from
|
||||
// "the selected scope is empty" for the start-card gate below.
|
||||
async function fetchLedgerHasAnyEntry(isCurrent: () => boolean) {
|
||||
// Back to unknown while the probe is in flight: a failed probe must not
|
||||
// leave a stale false behind, or the pristine card could render on data
|
||||
// this scope change never confirmed.
|
||||
if (isCurrent()) setLedgerHasAnyEntry(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/journal-entries?exclude_draft=true&limit=1')
|
||||
if (!res.ok || !isCurrent()) return
|
||||
const { count: total } = await res.json()
|
||||
if (isCurrent()) setLedgerHasAnyEntry((total || 0) > 0)
|
||||
} catch {
|
||||
// Non-fatal: an unknown probe keeps the pristine card hidden.
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap count-only query for the "Utkast" badge, all years, so the badge
|
||||
// surfaces drafts regardless of the selected fiscal-year scope.
|
||||
async function fetchDraftCount() {
|
||||
@@ -926,12 +957,18 @@ export default function JournalEntryList({ pristineSlot }: { pristineSlot?: Reac
|
||||
})
|
||||
}
|
||||
|
||||
// Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're
|
||||
// on the committed view. ONLY this genuinely-empty case may short-circuit the
|
||||
// whole component: every other empty state (a draft exists, or we're in the
|
||||
// drafts view) must fall through to the main render below so the
|
||||
// Pristine, untouched ledger: nothing posted in ANY year, no drafts, no
|
||||
// search or dialog filters, and we're on the committed view. The fiscal-year
|
||||
// scope deliberately does NOT count here: every company has a period
|
||||
// selected by default, and requiring "no scope" made this state unreachable
|
||||
// (the empty current year fell through to "inga träffar" on brand-new
|
||||
// ledgers). ledgerHasAnyEntry must be an explicit false: while the
|
||||
// all-years probe is in flight we show the filtered-empty state, never a
|
||||
// flash of the start card. ONLY this genuinely-empty case may short-circuit
|
||||
// the whole component: every other empty state (a draft exists, or we're in
|
||||
// the drafts view) must fall through to the main render below so the
|
||||
// Verifikat/Utkast toggle stays reachable.
|
||||
if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
if (!loading && entries.length === 0 && !loadFailed && !search && dialogFilterCount === 0 && ledgerHasAnyEntry === false && listMode === 'committed' && draftCount === 0) {
|
||||
if (pristineSlot) {
|
||||
return <>{pristineSlot}</>
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
|
||||
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
@@ -320,6 +320,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
// Its own input: sharing the header's would upload without the purchase.
|
||||
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -486,26 +487,36 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}, [fetchItems])
|
||||
|
||||
// Read the onboarding-dismissed flag from localStorage after mount
|
||||
// (SSR-safe: no window access during initial render).
|
||||
// (SSR-safe: no window access during initial render). Scoped per company:
|
||||
// dismissing the card on one company must not hide it on the user's other
|
||||
// companies. The legacy unscoped key is honored as "dismissed everywhere"
|
||||
// so users who dismissed before the scoping do not get the card back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
setOnboardingDismissed(
|
||||
window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1'
|
||||
)
|
||||
const legacy = window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1'
|
||||
const scoped = dismissKeyCompanyId
|
||||
? window.localStorage.getItem(`gnubok.inbox.onboarding.dismissed:${dismissKeyCompanyId}`) === '1'
|
||||
: false
|
||||
setOnboardingDismissed(legacy || scoped)
|
||||
} catch {
|
||||
// private browsing: keep default (show card)
|
||||
}
|
||||
}, [])
|
||||
}, [dismissKeyCompanyId])
|
||||
|
||||
const handleDismissOnboarding = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.setItem('gnubok.inbox.onboarding.dismissed', '1')
|
||||
window.localStorage.setItem(
|
||||
dismissKeyCompanyId
|
||||
? `gnubok.inbox.onboarding.dismissed:${dismissKeyCompanyId}`
|
||||
: 'gnubok.inbox.onboarding.dismissed',
|
||||
'1',
|
||||
)
|
||||
} catch {
|
||||
// ignore; in-memory state is enough for this session
|
||||
}
|
||||
setOnboardingDismissed(true)
|
||||
}, [])
|
||||
}, [dismissKeyCompanyId])
|
||||
|
||||
// Onboarding card visibility: derived from real progress so a user who
|
||||
// already has a working inbox flow never sees the guide. Once they finish
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
/**
|
||||
* Per-user toggle for the periodisering wizard's auto-detection step.
|
||||
* Per-company toggle for the periodisering wizard's auto-detection step.
|
||||
*
|
||||
* Backed by localStorage (key: `periodisering_autodetect_enabled`) because
|
||||
* Backed by localStorage (key: `periodisering_autodetect_enabled:<companyId>`,
|
||||
* with the old unscoped key as a read fallback so existing choices survive:
|
||||
* the unscoped key silently applied one company's choice to every company in
|
||||
* the browser) because
|
||||
* the company_settings table does not yet have a dedicated column for this
|
||||
* preference, and the task description explicitly allows the persistence to
|
||||
* be UI-local. A future migration can promote this to a real
|
||||
@@ -26,10 +30,16 @@ import {
|
||||
*/
|
||||
const STORAGE_KEY = 'periodisering_autodetect_enabled'
|
||||
|
||||
function readStored(): boolean {
|
||||
function storageKeyFor(companyId: string | null): string {
|
||||
return companyId ? `${STORAGE_KEY}:${companyId}` : STORAGE_KEY
|
||||
}
|
||||
|
||||
function readStored(companyId: string | null): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
const stored =
|
||||
window.localStorage.getItem(storageKeyFor(companyId)) ??
|
||||
window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === null ? true : stored !== 'false'
|
||||
} catch {
|
||||
return true
|
||||
@@ -42,7 +52,7 @@ function readStored(): boolean {
|
||||
function subscribe(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY || e.key === null) callback()
|
||||
if (e.key === null || e.key === STORAGE_KEY || e.key.startsWith(`${STORAGE_KEY}:`)) callback()
|
||||
}
|
||||
const customHandler = () => callback()
|
||||
window.addEventListener('storage', handler)
|
||||
@@ -61,9 +71,11 @@ function notifyChange() {
|
||||
}
|
||||
|
||||
export function PeriodiseringAutoDetectToggle() {
|
||||
const companyId = useCompanyOptional()?.company?.id ?? null
|
||||
const getSnapshot = useMemo(() => () => readStored(companyId), [companyId])
|
||||
const enabled = useSyncExternalStore(
|
||||
subscribe,
|
||||
readStored,
|
||||
getSnapshot,
|
||||
// Server snapshot: default to enabled. Matches the client default so
|
||||
// hydration is identical.
|
||||
() => true,
|
||||
@@ -71,12 +83,12 @@ export function PeriodiseringAutoDetectToggle() {
|
||||
|
||||
const handleChange = useCallback((value: boolean) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, String(value))
|
||||
window.localStorage.setItem(storageKeyFor(companyId), String(value))
|
||||
} catch {
|
||||
// No-op; if storage is blocked the toggle simply won't persist.
|
||||
}
|
||||
notifyChange()
|
||||
}, [])
|
||||
}, [companyId])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('gnubok_vat_declaration_validate', () => {
|
||||
expect(result.redovisningsperiod).toBe('202503')
|
||||
// Only /kontrollera was called: nothing was saved at SKV.
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('gnubok_vat_declaration_submit', () => {
|
||||
expect(result.preview.commit_action).toMatch(/signering/i)
|
||||
// Exactly one SKV call (the stage-time /kontrollera); no /utkast.
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ describe('gnubok_vat_declaration_validate', () => {
|
||||
expect(result.completeness_ok).toBe(true)
|
||||
expect(result.completeness_checks).toEqual([])
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
|
||||
})
|
||||
|
||||
// The masking case: rutor 30-32 are compared against 2645/2647, so ordinary
|
||||
|
||||
@@ -11228,7 +11228,7 @@ export const tools: McpTool[] = [
|
||||
const { redovisare, redovisningsperiod, momsuppgift } =
|
||||
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
|
||||
const res = await skvRequest(
|
||||
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
supabase, userId, companyId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'kontrollera', agRegistreradId: redovisare, redovisningsperiod,
|
||||
@@ -11298,7 +11298,7 @@ export const tools: McpTool[] = [
|
||||
try {
|
||||
const prep = await buildMomsuppgift(supabase, companyId, { periodType, year, period })
|
||||
const res = await skvRequest(
|
||||
supabase, userId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift,
|
||||
supabase, userId, companyId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift,
|
||||
)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'kontrollera', agRegistreradId: prep.redovisare, redovisningsperiod: prep.redovisningsperiod,
|
||||
@@ -11365,7 +11365,7 @@ export const tools: McpTool[] = [
|
||||
let submitted: unknown = null
|
||||
let decided: unknown = null
|
||||
if (state === 'submitted' || state === 'both') {
|
||||
const res = await skvRequest(supabase, userId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`)
|
||||
const res = await skvRequest(supabase, userId, companyId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'inlamnat', agRegistreradId: redovisare, redovisningsperiod,
|
||||
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
|
||||
@@ -11379,7 +11379,7 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
}
|
||||
if (state === 'decided' || state === 'both') {
|
||||
const res = await skvRequest(supabase, userId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`)
|
||||
const res = await skvRequest(supabase, userId, companyId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'beslutat', agRegistreradId: redovisare, redovisningsperiod,
|
||||
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
|
||||
@@ -11524,7 +11524,7 @@ export const tools: McpTool[] = [
|
||||
// leaves kvittenser null rather than hard-failing the status check;
|
||||
// auth errors throw and map to SKATTEVERKET_NOT_CONNECTED.
|
||||
let kvittenser: unknown = null
|
||||
const res = await agiGetKvittenser({ mode: 'user', supabase, userId }, arbetsgivare, period)
|
||||
const res = await agiGetKvittenser({ mode: 'user', supabase, userId, companyId }, arbetsgivare, period)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'kvittenser', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
||||
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps empty 401 → ACCESS_DENIED (likely missing APIGW subscription)', async () => {
|
||||
mockFetchStatus(401)
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -59,7 +59,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps 401 with body text → SESSION_EXPIRED with a clean Swedish message (no body leak)', async () => {
|
||||
mockFetchStatus(401, 'token expired')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -75,13 +75,13 @@ describe('skvRequest: error mapping', () => {
|
||||
deleteTokensMock.mockClear()
|
||||
mockFetchStatus(401, '{"error":"Token has been revoked."}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED')
|
||||
expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i)
|
||||
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1')
|
||||
expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1', 'comp-1')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('skvRequest: error mapping', () => {
|
||||
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
|
||||
})
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -101,7 +101,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
|
||||
mockFetchStatus(403, 'Behörighet saknas för aktören')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -118,7 +118,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps the APIGW "required scopes are not authorized" 403 → ACCESS_DENIED, not MISSING_SCOPE', async () => {
|
||||
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
|
||||
@@ -133,7 +133,7 @@ describe('skvRequest: error mapping', () => {
|
||||
'{"error":"invalid_scope","description":"The required scope agd has been requested for that access token."}',
|
||||
)
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
|
||||
@@ -143,7 +143,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps the SKV scope sentence alone (no invalid_scope code) → MISSING_SCOPE', async () => {
|
||||
mockFetchStatus(403, 'The required scope agd has been requested for that access token.')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
|
||||
@@ -158,7 +158,7 @@ describe('skvRequest: error mapping', () => {
|
||||
'WWW-Authenticate': 'Bearer error="invalid_scope"',
|
||||
})
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
|
||||
@@ -168,7 +168,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps generic 403 → ACCESS_DENIED', async () => {
|
||||
mockFetchStatus(403, 'Forbidden')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -179,7 +179,7 @@ describe('skvRequest: error mapping', () => {
|
||||
it('maps 429 → RATE_LIMITED (new behavior)', async () => {
|
||||
mockFetchStatus(429)
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
@@ -192,13 +192,13 @@ describe('skvRequest: error mapping', () => {
|
||||
|
||||
it('returns the response for 5xx (caller decides retry)', async () => {
|
||||
mockFetchStatus(503, 'Service Unavailable')
|
||||
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
const res = await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('returns the response for success', async () => {
|
||||
mockFetchStatus(200, '{"ok":true}')
|
||||
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
|
||||
const res = await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json).toEqual({ ok: true })
|
||||
|
||||
@@ -73,10 +73,10 @@ describe('commitSubmitVatDeclaration', () => {
|
||||
expect(result).toMatchObject({ ok: true, signing_url: 'https://skv.test/sign/abc' })
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
|
||||
// call order: utkast (POST) before las (PUT)
|
||||
expect(mockSkvRequest.mock.calls[0][2]).toBe('POST')
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/utkast\/165560000000\/202503$/)
|
||||
expect(mockSkvRequest.mock.calls[1][2]).toBe('PUT')
|
||||
expect(mockSkvRequest.mock.calls[1][3]).toMatch(/^\/las\/165560000000\/202503$/)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toBe('POST')
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/utkast\/165560000000\/202503$/)
|
||||
expect(mockSkvRequest.mock.calls[1][3]).toBe('PUT')
|
||||
expect(mockSkvRequest.mock.calls[1][4]).toMatch(/^\/las\/165560000000\/202503$/)
|
||||
})
|
||||
|
||||
it('utkast rejected by SKV → non-recoverable, no /las call', async () => {
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('runPostConnectRefresh', () => {
|
||||
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
||||
|
||||
expect(result.synced).toBe(false)
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, 'MISSING_SCOPE')
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, COMPANY, 'MISSING_SCOPE')
|
||||
})
|
||||
|
||||
it('does not persist needs_reconsent for non-terminal auth error codes', async () => {
|
||||
|
||||
@@ -20,13 +20,15 @@ beforeEach(() => {
|
||||
|
||||
async function mockSelectReturning(row: unknown) {
|
||||
const { createClient } = await import('@supabase/supabase-js')
|
||||
// getTokens chains .eq('user_id', …).eq('company_id', …).maybeSingle():
|
||||
// a self-referencing eq keeps the chain length out of the mock's contract.
|
||||
const eqChain: { eq: ReturnType<typeof vi.fn>; maybeSingle: ReturnType<typeof vi.fn> } = {
|
||||
eq: vi.fn(() => eqChain),
|
||||
maybeSingle: vi.fn(async () => row),
|
||||
}
|
||||
;(createClient as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn(async () => row),
|
||||
})),
|
||||
})),
|
||||
select: vi.fn(() => eqChain),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -38,7 +40,7 @@ describe('getTokens', () => {
|
||||
// mock changes. Use vitest's resetModules to force re-import.
|
||||
vi.resetModules()
|
||||
const { getTokens: fresh } = await import('../lib/token-store')
|
||||
const result = await fresh(fakeSupabase, 'user-1')
|
||||
const result = await fresh(fakeSupabase, 'user-1', 'comp-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -61,7 +63,7 @@ describe('getTokens', () => {
|
||||
const { getTokens: fresh } = await import('../lib/token-store')
|
||||
|
||||
try {
|
||||
await fresh(fakeSupabase, 'user-1')
|
||||
await fresh(fakeSupabase, 'user-1', 'comp-1')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
const err = e as { name: string; code: string; message: string }
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('submitVatDeclarationChain', () => {
|
||||
})
|
||||
// Only the kontrollera call: no utkast, no lås, no persisted state.
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toBe('/kontrollera/165560000000/202606')
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toBe('/kontrollera/165560000000/202606')
|
||||
expect((ctx.settings.set as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('submitVatDeclarationChain', () => {
|
||||
|
||||
expect(result).toMatchObject({ ok: true, signingUrl: 'https://skv.test/sign/xyz' })
|
||||
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
|
||||
expect(mockSkvRequest.mock.calls[0][3]).toBe('/utkast/165560000000/202606')
|
||||
expect(mockSkvRequest.mock.calls[0][4]).toBe('/utkast/165560000000/202606')
|
||||
})
|
||||
|
||||
it('utkast failure -> stage draft, nothing saved, retry-safe', async () => {
|
||||
|
||||
@@ -550,7 +550,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const tokens = await getTokens(ctx.supabase, ctx.userId)
|
||||
const tokens = await getTokens(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
const environment = getSkatteverketEnvironment()
|
||||
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
|
||||
|
||||
@@ -564,7 +564,7 @@ export const skatteverketExtension: Extension = {
|
||||
// Persisted health, written by the crons when they hit a terminal
|
||||
// auth state. Lets the settings panel prompt for re-consent
|
||||
// proactively instead of only after a live failure.
|
||||
const health = await getTokenHealth(ctx.supabase, ctx.userId)
|
||||
const health = await getTokenHealth(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
|
||||
return NextResponse.json({
|
||||
connected: true,
|
||||
@@ -589,7 +589,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
await deleteTokens(ctx.supabase, ctx.userId)
|
||||
await deleteTokens(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
@@ -752,6 +752,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'POST',
|
||||
`/kontrollera/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -800,6 +801,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'POST',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -850,6 +852,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'GET',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -889,6 +892,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'DELETE',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -928,6 +932,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'PUT',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -975,6 +980,7 @@ export const skatteverketExtension: Extension = {
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'DELETE',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -1211,7 +1217,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
|
||||
|
||||
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
|
||||
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, ctx.companyId, xml)
|
||||
if (!result.ok) {
|
||||
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
|
||||
return NextResponse.json(
|
||||
@@ -1268,7 +1274,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
|
||||
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, code: result.body?.kod },
|
||||
@@ -1338,7 +1344,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Saknar inlamningId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, inlamningId)
|
||||
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, code: result.body?.kod },
|
||||
@@ -1426,7 +1432,7 @@ export const skatteverketExtension: Extension = {
|
||||
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
|
||||
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
|
||||
}
|
||||
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, inlamningId)
|
||||
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, ctx.companyId, inlamningId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, code: result.body?.kod },
|
||||
@@ -1493,7 +1499,7 @@ export const skatteverketExtension: Extension = {
|
||||
)
|
||||
}
|
||||
const result = await agiTaBortSparadInlamning(
|
||||
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
|
||||
ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period, inlamningId,
|
||||
)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -1534,7 +1540,7 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
|
||||
const result = await agiSkapaGranskningsunderlag(
|
||||
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
|
||||
ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period, { lasPeriod },
|
||||
)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -1618,7 +1624,7 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
|
||||
const result = await agiGetKvittenser(
|
||||
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
|
||||
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId, companyId: ctx.companyId },
|
||||
arbetsgivare,
|
||||
period
|
||||
)
|
||||
@@ -1800,7 +1806,7 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data)
|
||||
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, ctx.companyId, parsed.data)
|
||||
if (!result.ok) {
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'agi.kontrollera.hu',
|
||||
@@ -1908,7 +1914,7 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data)
|
||||
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, ctx.companyId, parsed.data)
|
||||
if (!result.ok) {
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'agi.kontrollera.iu',
|
||||
@@ -1968,7 +1974,7 @@ export const skatteverketExtension: Extension = {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const result = await agiLasPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
|
||||
const result = await agiLasPeriod(ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, code: result.body?.kod },
|
||||
@@ -1998,7 +2004,7 @@ export const skatteverketExtension: Extension = {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
|
||||
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, ctx.companyId, arbetsgivare, period)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, code: result.body?.kod },
|
||||
@@ -2056,6 +2062,19 @@ export const skatteverketExtension: Extension = {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
// The Skattekonto page keys its "inte anslutet" empty state off a 401
|
||||
// NOT_CONNECTED from this route. Connections are per (user, company):
|
||||
// without this check, a company that never connected rendered the
|
||||
// connected-but-unsynced view because the snapshot read below always
|
||||
// answered 200 (with null data), and "Synkronisera nu" then died on a
|
||||
// behorighet error at SKV.
|
||||
const tokens = await getTokens(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!tokens) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
|
||||
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
|
||||
return NextResponse.json({
|
||||
@@ -2630,7 +2649,7 @@ async function commitSubmitAgi(
|
||||
await buildAgiUnderlag(supabase, companyId, salaryRunId)
|
||||
|
||||
// 1. POST /underlag (XML) → inlamningId.
|
||||
const submit = await agiPostUnderlag(supabase, userId, xml)
|
||||
const submit = await agiPostUnderlag(supabase, userId, companyId, xml)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'agi/submit', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
||||
outcome: submit.ok ? 'ok' : 'skv_error', responseStatus: submit.status,
|
||||
@@ -2647,10 +2666,10 @@ async function commitSubmitAgi(
|
||||
}))
|
||||
|
||||
// 2. Poll kontrollresultat (bounded; SKV is typically sub-second).
|
||||
let kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
|
||||
let kontroll = await agiGetKontrollresultat(supabase, userId, companyId, inlamningId)
|
||||
for (let i = 0; i < 2 && kontroll.ok && kontroll.data.status === 'PROCESSING'; i++) {
|
||||
await sleep(750)
|
||||
kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
|
||||
kontroll = await agiGetKontrollresultat(supabase, userId, companyId, inlamningId)
|
||||
}
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'agi/kontrollresultat', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
||||
@@ -2678,7 +2697,7 @@ async function commitSubmitAgi(
|
||||
}
|
||||
|
||||
// 3. skapaGranskningsunderlag (lasPeriod=true) → Mina Sidor signing link.
|
||||
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, arbetsgivare, period, { lasPeriod: true })
|
||||
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, companyId, arbetsgivare, period, { lasPeriod: true })
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'agi/granskningsunderlag', agRegistreradId: arbetsgivare, redovisningsperiod: period,
|
||||
outcome: gransk.ok ? 'ok' : 'skv_error', responseStatus: gransk.status,
|
||||
|
||||
@@ -85,11 +85,13 @@ async function readErrorBody(response: Response): Promise<{ error: string; body?
|
||||
export async function agiPostUnderlag(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
xml: string,
|
||||
): Promise<Result<SkatteverketAGIUnderlagResponse>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
'/underlag',
|
||||
xml,
|
||||
@@ -118,11 +120,13 @@ export async function agiPostUnderlag(
|
||||
export async function agiGetKontrollresultat(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
inlamningId: number,
|
||||
): Promise<Result<SkatteverketAGIKontrollresultat>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'GET',
|
||||
`/underlag/${inlamningId}/kontrollresultat`,
|
||||
undefined,
|
||||
@@ -155,11 +159,13 @@ export async function agiGetKontrollresultat(
|
||||
export async function agiSparaUnderlag(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
inlamningId: number,
|
||||
): Promise<Result<unknown>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
`/underlag/${inlamningId}/spara`,
|
||||
undefined,
|
||||
@@ -185,11 +191,13 @@ export async function agiSparaUnderlag(
|
||||
export async function agiAvbrytUnderlag(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
inlamningId: number,
|
||||
): Promise<Result<unknown>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'DELETE',
|
||||
`/underlag/${inlamningId}`,
|
||||
undefined,
|
||||
@@ -213,6 +221,7 @@ export async function agiAvbrytUnderlag(
|
||||
export async function agiTaBortSparadInlamning(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
inlamningId: number,
|
||||
@@ -220,6 +229,7 @@ export async function agiTaBortSparadInlamning(
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'DELETE',
|
||||
`${periodPath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
|
||||
undefined,
|
||||
@@ -245,6 +255,7 @@ export async function agiTaBortSparadInlamning(
|
||||
export async function agiSkapaGranskningsunderlag(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
options: { lasPeriod?: boolean } = {},
|
||||
@@ -253,6 +264,7 @@ export async function agiSkapaGranskningsunderlag(
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
`${periodPath(arbetsgivare, period)}/skapaGranskningsunderlag${qs}`,
|
||||
undefined,
|
||||
@@ -317,12 +329,14 @@ export async function agiGetKvittenser(
|
||||
export async function agiLasPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
): Promise<Result<unknown>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
`${periodPath(arbetsgivare, period)}/las`,
|
||||
undefined,
|
||||
@@ -340,12 +354,14 @@ export async function agiLasPeriod(
|
||||
export async function agiLasUppPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
): Promise<Result<unknown>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
`${periodPath(arbetsgivare, period)}/lasUpp`,
|
||||
undefined,
|
||||
@@ -372,11 +388,13 @@ export async function agiLasUppPeriod(
|
||||
export async function agiKontrolleraHU(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
hu: Record<string, unknown>,
|
||||
): Promise<Result<SkatteverketAGIKontrollsvar>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
'/underlag/huvuduppgift/kontrollera',
|
||||
hu,
|
||||
@@ -401,11 +419,13 @@ export async function agiKontrolleraHU(
|
||||
export async function agiKontrolleraIU(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
iu: Record<string, unknown>,
|
||||
): Promise<Result<SkatteverketAGIKontrollsvar>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
'POST',
|
||||
'/underlag/individuppgift/kontrollera',
|
||||
iu,
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { SkatteverketTokens } from '../types'
|
||||
* Used by background reads; carries no user session at all.
|
||||
*/
|
||||
export type SkvAuth =
|
||||
| { mode: 'user'; supabase: SupabaseClient; userId: string }
|
||||
| { mode: 'user'; supabase: SupabaseClient; userId: string; companyId: string }
|
||||
| { mode: 'system' }
|
||||
|
||||
const log = createLogger('skatteverket-api-client')
|
||||
@@ -123,9 +123,10 @@ const refreshInFlight = new Map<string, Promise<string>>()
|
||||
*/
|
||||
async function getValidToken(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<string> {
|
||||
const tokens = await getTokens(supabase, userId)
|
||||
const tokens = await getTokens(supabase, userId, companyId)
|
||||
if (!tokens) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Inte ansluten till Skatteverket. Anslut med BankID först.',
|
||||
@@ -138,24 +139,26 @@ async function getValidToken(
|
||||
return tokens.access_token
|
||||
}
|
||||
|
||||
// Need refresh: coalesce concurrent attempts.
|
||||
const inFlight = refreshInFlight.get(userId)
|
||||
// Need refresh: coalesce concurrent attempts per (user, company) row.
|
||||
const flightKey = `${userId}:${companyId}`
|
||||
const inFlight = refreshInFlight.get(flightKey)
|
||||
if (inFlight) return inFlight
|
||||
|
||||
const promise = refreshTokenForUser(supabase, userId)
|
||||
.finally(() => refreshInFlight.delete(userId))
|
||||
refreshInFlight.set(userId, promise)
|
||||
const promise = refreshTokenForUser(supabase, userId, companyId)
|
||||
.finally(() => refreshInFlight.delete(flightKey))
|
||||
refreshInFlight.set(flightKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function refreshTokenForUser(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
): Promise<string> {
|
||||
// Re-read after entering the critical section. Another process may have
|
||||
// refreshed while we were waiting; if so, the row now has a new
|
||||
// refresh_token and a future expiry: just hand it back.
|
||||
const tokens = await getTokens(supabase, userId)
|
||||
const tokens = await getTokens(supabase, userId, companyId)
|
||||
if (!tokens) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Inte ansluten till Skatteverket. Anslut med BankID först.',
|
||||
@@ -212,7 +215,7 @@ async function refreshTokenForUser(
|
||||
...refreshed,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
await storeTokens(supabase, userId, updatedTokens)
|
||||
await storeTokens(supabase, userId, updatedTokens, companyId)
|
||||
return updatedTokens.access_token
|
||||
}
|
||||
|
||||
@@ -260,12 +263,13 @@ function isTokenScopeRejection(body: string): boolean {
|
||||
export async function skvRequest(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: { baseUrl?: string; contentType?: string }
|
||||
): Promise<Response> {
|
||||
return skvRequestWithAuth({ mode: 'user', supabase, userId }, method, path, body, options)
|
||||
return skvRequestWithAuth({ mode: 'user', supabase, userId, companyId }, method, path, body, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,7 +303,7 @@ export async function skvRequestWithAuth(
|
||||
|
||||
let accessToken: string
|
||||
if (auth.mode === 'user') {
|
||||
accessToken = await getValidToken(auth.supabase, auth.userId)
|
||||
accessToken = await getValidToken(auth.supabase, auth.userId, auth.companyId)
|
||||
} else {
|
||||
try {
|
||||
accessToken = await getSystemAccessToken()
|
||||
@@ -437,7 +441,7 @@ export async function skvRequestWithAuth(
|
||||
// primary auth error to the user.
|
||||
if (lower.includes('revoked') || lower.includes('token has been revoked')) {
|
||||
try {
|
||||
await deleteTokens(auth.supabase, auth.userId)
|
||||
await deleteTokens(auth.supabase, auth.userId, auth.companyId)
|
||||
} catch (cleanupErr) {
|
||||
log.error('failed to clear revoked token row', cleanupErr as Error, { userId: auth.userId })
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function runPostConnectRefresh(
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
await markNeedsReconsent(supabase, userId, companyId, err.code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function resolveReadAuth(
|
||||
if (opts.userId) {
|
||||
return {
|
||||
ok: true,
|
||||
auth: { mode: 'user', supabase, userId: opts.userId },
|
||||
auth: { mode: 'user', supabase, userId: opts.userId, companyId },
|
||||
source: 'user',
|
||||
tokenUserId: opts.userId,
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export async function resolveReadAuth(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
auth: { mode: 'user', supabase, userId: token.userId },
|
||||
auth: { mode: 'user', supabase, userId: token.userId, companyId },
|
||||
source: 'user',
|
||||
tokenUserId: token.userId,
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function syncSkattekonto(
|
||||
// Defaults to the ctx user's personal token: the interactive manual-sync
|
||||
// route keeps its exact pre-hybrid behavior. The cron passes system auth
|
||||
// for companies with a verified lasombud grant.
|
||||
auth: SkvAuth = { mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
|
||||
auth: SkvAuth = { mode: 'user', supabase: ctx.supabase, userId: ctx.userId, companyId: ctx.companyId },
|
||||
): Promise<SkattekontoSyncResult> {
|
||||
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
|
||||
|
||||
|
||||
@@ -65,63 +65,41 @@ function decrypt(ciphertext: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (replace) Skatteverket tokens for a user.
|
||||
* Store (replace) Skatteverket tokens for a user + company pair.
|
||||
* Both access_token and refresh_token are encrypted at rest.
|
||||
*
|
||||
* Implemented as DELETE + INSERT instead of UPSERT because some environments
|
||||
* are missing the UNIQUE(user_id) constraint that ON CONFLICT requires. The
|
||||
* delete-then-insert pattern is safe because OAuth callbacks for a given user
|
||||
* are not concurrent (the user can only sign in with BankID once at a time).
|
||||
* Connections are per (user_id, company_id): the OAuth token is the person's
|
||||
* BankID session, but which company it is wired to is an explicit product
|
||||
* choice, and a multi-company operator holds one row per company. The
|
||||
* DELETE + INSERT (instead of UPSERT) predates the UNIQUE constraint and is
|
||||
* safe because OAuth callbacks and refreshes for a given pair are coalesced.
|
||||
*/
|
||||
export async function storeTokens(
|
||||
_supabase: SupabaseClient,
|
||||
userId: string,
|
||||
tokens: SkatteverketTokens,
|
||||
companyId?: string,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
const encryptedAccess = encrypt(tokens.access_token)
|
||||
const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null
|
||||
const db = getServiceClient()
|
||||
|
||||
// The multi-tenant refactor (migration 20260330130000) put a NOT NULL
|
||||
// company_id on every table. Tokens are conceptually user-scoped (one
|
||||
// BankID identity), but the schema requires a company_id. The OAuth
|
||||
// callback passes one explicitly. Token-refresh flows (called from
|
||||
// skvRequest) don't pass one, so before we DELETE the existing row we
|
||||
// remember its company_id and reuse it on INSERT.
|
||||
let resolvedCompanyId = companyId
|
||||
if (!resolvedCompanyId) {
|
||||
const { data: existing, error: selectError } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.select('company_id')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
// Throw before the destructive DELETE: a transient read failure here
|
||||
// would otherwise wipe the existing row and then fail the INSERT on the
|
||||
// NOT NULL company_id, leaving the user with no token at all.
|
||||
if (selectError) {
|
||||
throw new Error(`Failed to read existing token row: ${selectError.message}`)
|
||||
}
|
||||
if (existing?.company_id) resolvedCompanyId = existing.company_id
|
||||
}
|
||||
|
||||
const { error: deleteError } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.message}`)
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
const { error: insertError } = await db.from('skatteverket_tokens').insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
access_token: encryptedAccess,
|
||||
refresh_token: encryptedRefresh,
|
||||
expires_at: new Date(tokens.expires_at).toISOString(),
|
||||
refresh_count: tokens.refresh_count,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
if (resolvedCompanyId) row.company_id = resolvedCompanyId
|
||||
|
||||
const { error: insertError } = await db.from('skatteverket_tokens').insert(row)
|
||||
})
|
||||
if (insertError) throw new Error(`Failed to store tokens: ${insertError.message}`)
|
||||
}
|
||||
|
||||
@@ -131,14 +109,16 @@ export async function storeTokens(
|
||||
*/
|
||||
export async function getTokens(
|
||||
_supabase: SupabaseClient,
|
||||
userId: string
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<SkatteverketTokens | null> {
|
||||
const db = getServiceClient()
|
||||
const { data, error } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.select('access_token, refresh_token, expires_at, refresh_count, scope')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !data) return null
|
||||
|
||||
@@ -159,6 +139,7 @@ export async function getTokens(
|
||||
} catch (err) {
|
||||
log.error('decryption failed for stored tokens', {
|
||||
userId,
|
||||
companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw new SkatteverketAuthError(
|
||||
@@ -173,13 +154,15 @@ export async function getTokens(
|
||||
*/
|
||||
export async function deleteTokens(
|
||||
_supabase: SupabaseClient,
|
||||
userId: string
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<void> {
|
||||
const db = getServiceClient()
|
||||
await db
|
||||
.from('skatteverket_tokens')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +187,7 @@ export const RECONSENT_ERROR_CODES = [
|
||||
export async function markNeedsReconsent(
|
||||
_supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
errorCode: string,
|
||||
): Promise<void> {
|
||||
const db = getServiceClient()
|
||||
@@ -215,6 +199,7 @@ export async function markNeedsReconsent(
|
||||
last_error_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
if (error) {
|
||||
log.warn('failed to mark token row needs_reconsent', {
|
||||
userId,
|
||||
@@ -231,12 +216,14 @@ export async function markNeedsReconsent(
|
||||
export async function getTokenHealth(
|
||||
_supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
): Promise<{ status: string; last_error_code: string | null; last_error_at: string | null } | null> {
|
||||
const db = getServiceClient()
|
||||
const { data, error } = await db
|
||||
.from('skatteverket_tokens')
|
||||
.select('status, last_error_code, last_error_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
return {
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function submitVatDeclarationChain(
|
||||
params: VatSubmitChainParams,
|
||||
options: { validate?: boolean } = {}
|
||||
): Promise<VatSubmitChainResult> {
|
||||
const { supabase, userId } = ctx
|
||||
const { supabase, userId, companyId } = ctx
|
||||
const { redovisare, redovisningsperiod, momsuppgift } =
|
||||
await buildMomsuppgift(supabase, ctx.companyId, params)
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function submitVatDeclarationChain(
|
||||
// any state exists at Skatteverket.
|
||||
if (options.validate) {
|
||||
const kontrollera = await skvRequest(
|
||||
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
supabase, userId, companyId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'declaration/validate', agRegistreradId: redovisare, redovisningsperiod,
|
||||
@@ -99,7 +99,7 @@ export async function submitVatDeclarationChain(
|
||||
// 1. POST /utkast: save the draft to Eget utrymme. Overwrites any prior
|
||||
// draft for the period, so retry after a mid-chain failure is safe.
|
||||
const utkast = await skvRequest(
|
||||
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
supabase, userId, companyId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
|
||||
)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
|
||||
@@ -128,7 +128,7 @@ export async function submitVatDeclarationChain(
|
||||
|
||||
// 2. PUT /las: lock for signing; returns the BankID signeringslänk.
|
||||
const las = await skvRequest(
|
||||
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
|
||||
supabase, userId, companyId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
|
||||
)
|
||||
await writeSkatteverketAudit(ctx, {
|
||||
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Skatteverket connections are per (user, company).
|
||||
--
|
||||
-- The table historically carried two half-finished uniqueness models stacked
|
||||
-- on top of each other:
|
||||
-- * 20260330130000 replaced UNIQUE(user_id) with UNIQUE(company_id)
|
||||
-- * 20260428120000 re-added UNIQUE(user_id) (its guard only looked for a
|
||||
-- (user_id) unique constraint, so it did not see the company one)
|
||||
-- leaving BOTH single-column constraints in place: one row per user AND one
|
||||
-- row per company. A multi-company operator could therefore never hold a
|
||||
-- connection for more than one of their companies, and reconnecting while
|
||||
-- another company was active silently moved the single row (the app's
|
||||
-- DELETE-by-user + INSERT-with-active-company pattern), going dark on the
|
||||
-- original company's nightly sync.
|
||||
--
|
||||
-- The application now reads and writes token rows scoped by
|
||||
-- (user_id, company_id); this migration makes the schema say the same thing.
|
||||
-- Legacy rows with NULL company_id (pre-multi-tenant) are left in place: the
|
||||
-- scoped reads never match them, so those users simply reconnect per company.
|
||||
|
||||
ALTER TABLE public.skatteverket_tokens
|
||||
DROP CONSTRAINT IF EXISTS skatteverket_tokens_user_id_key;
|
||||
|
||||
ALTER TABLE public.skatteverket_tokens
|
||||
DROP CONSTRAINT IF EXISTS skatteverket_tokens_company_id_key;
|
||||
|
||||
ALTER TABLE public.skatteverket_tokens
|
||||
ADD CONSTRAINT skatteverket_tokens_user_id_company_id_key UNIQUE (user_id, company_id);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for 20260814060000_skatteverket_tokens_per_company.sql.
|
||||
*
|
||||
* Skatteverket connections are per (user, company). The table used to carry
|
||||
* BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half-migrations),
|
||||
* which made it impossible for a multi-company operator to hold a connection
|
||||
* for more than one of their companies: the root cause of the cross-company
|
||||
* "connected" leak on the Skattekonto page. This suite locks in the composite
|
||||
* key so neither single-column constraint can quietly come back.
|
||||
*/
|
||||
|
||||
async function insertTokenRow(userId: string, companyId: string): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.skatteverket_tokens
|
||||
(user_id, company_id, access_token, refresh_token, expires_at)
|
||||
VALUES ($1, $2, 'enc-access', 'enc-refresh', now() + interval '1 hour')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
}
|
||||
|
||||
describe('skatteverket_tokens per-company uniqueness', () => {
|
||||
it('allows the same user to hold one connection per company', async () => {
|
||||
const a = await seedCompany()
|
||||
// Second company owned by the same user: insert the membershipless
|
||||
// company row directly; the constraint only concerns (user_id, company_id).
|
||||
const b = await seedCompany()
|
||||
|
||||
await expect(insertTokenRow(a.userId, a.companyId)).resolves.not.toThrow()
|
||||
// Different user, different company: must not collide with a's row
|
||||
// (UNIQUE(user_id) would have rejected a second row for the same user;
|
||||
// UNIQUE(company_id) would have rejected a second row for the company).
|
||||
await expect(insertTokenRow(a.userId, b.companyId)).resolves.not.toThrow()
|
||||
await expect(insertTokenRow(b.userId, a.companyId)).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a second row for the same (user, company) pair', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await insertTokenRow(userId, companyId)
|
||||
await expect(insertTokenRow(userId, companyId)).rejects.toThrow(
|
||||
/skatteverket_tokens_user_id_company_id_key/,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user