fix(skatteverket): let company members read data connected by another member (#1691)

Closes #1673. Token rows are per (user, company), but the read resolver
short-circuited to the caller's own row whenever a userId was passed, so a
member who never pressed "Anslut" resolved to "no token" for a company that
was connected. The company-wide fallback used .maybeSingle(), which errors
as soon as two members have both connected and turned that into "nobody
connected" for everyone.

- resolve-auth: findCompanyTokenUser() reads all of the company's rows
  ordered by created_at desc and picks the caller's own active row first,
  then any other member's active row, then needs_reconsent rows; the userId
  branch no longer short-circuits. The auth carries the token OWNER's userId
  so refresh writes back to the owner's row.
- /skattekonto/saldo and /skattekonto/sync resolve the company token instead
  of getTokens(caller); /declaration/submitted and /decided answer a
  SESSION_EXPIRED reconnect prompt for needs_reconsent instead of
  NOT_CONNECTED. Connect/disconnect//status stay on the caller's own row.
- The connection.expired event names the token owner, not the caller who
  triggered the sync; the notification lookup filters by company too.
- The two kvittens crons flag needs_reconsent through the same shared pick
  instead of .maybeSingle().

Tests: two members, one connects, both read; both connect, both read; no
row -> NOT_CONNECTED; dead-only rows -> reconnect prompt; sync auth carries
the owner; event recipient is the owner.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-18 16:40:10 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 619b446c52
commit 1af5846adf
13 changed files with 585 additions and 86 deletions
@@ -147,7 +147,7 @@ function makeSupabaseStub(tables: Record<string, { data: unknown; error?: unknow
function stubHappyTables() {
return makeSupabaseStub({
agi_declarations: { data: [PENDING_DECLARATION] },
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
skatteverket_tokens: { data: [{ user_id: 'user-1', status: 'active' }] },
company_settings: { data: { org_number: '556123-4567', entity_type: 'aktiebolag' } },
})
}
@@ -363,7 +363,7 @@ describe('AGI kvittenser cron', () => {
{ ...PENDING_DECLARATION, id: 'decl-3', company_id: 'comp-3' },
],
},
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
skatteverket_tokens: { data: [{ user_id: 'user-1', status: 'active' }] },
company_settings: { data: { org_number: '556123-4567', entity_type: 'aktiebolag' } },
}),
)
@@ -5,7 +5,7 @@ import { createLogger } from '@/lib/logger'
import { verifyCronSecret } from '@/lib/auth/cron'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { currentSkvEnvironment, findCompanyTokenUser } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { reconcileAgiDeclaration } from '@/extensions/general/skatteverket/lib/agi-kvittens-reconcile'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
@@ -166,13 +166,12 @@ export async function GET(request: Request) {
) {
// Persist the health flag so both crons stop retrying this
// connection and the UI can prompt for re-consent proactively.
const { data: tokenRow } = await supabase
.from('skatteverket_tokens')
.select('user_id')
.eq('company_id', companyId)
.maybeSingle()
if (tokenRow?.user_id) {
await markNeedsReconsent(supabase, tokenRow.user_id as string, companyId, err.code)
// Same pick as the reconciler's resolveReadAuth (a company can hold
// one row per connected member), so the flag lands on the row that
// just failed.
const tokenRow = await findCompanyTokenUser(supabase, companyId)
if (tokenRow) {
await markNeedsReconsent(supabase, tokenRow.userId, companyId, err.code)
}
results.push({ declarationId, period, status: 'expired_token', error: err.code })
continue
@@ -121,7 +121,7 @@ function stubHappyTables(state: Record<string, unknown> = LOCKED_STATE) {
extension_data: {
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(state) }],
},
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
skatteverket_tokens: { data: [{ user_id: 'user-1', status: 'active' }] },
})
}
@@ -312,7 +312,7 @@ describe('VAT kvittenser cron', () => {
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) }],
updateError: { message: 'connection reset', code: '08006' },
},
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
skatteverket_tokens: { data: [{ user_id: 'user-1', status: 'active' }] },
}),
)
mockSkvRequest.mockResolvedValueOnce({
@@ -345,7 +345,7 @@ describe('VAT kvittenser cron', () => {
{ company_id: 'comp-2', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) },
],
},
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
skatteverket_tokens: { data: [{ user_id: 'user-1', status: 'active' }] },
})
}
@@ -5,7 +5,7 @@ import { verifyCronSecret } from '@/lib/auth/cron'
import { skvRequestWithAuth, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { resolveReadAuth, currentSkvEnvironment, findCompanyTokenUser } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
import { hasCapability } from '@/lib/entitlements/has-capability'
@@ -260,13 +260,11 @@ export async function GET(request: Request) {
// connection. Best-effort: a failure here must not abort the
// remaining companies' rows.
try {
const { data: tokenRow } = await supabase
.from('skatteverket_tokens')
.select('user_id')
.eq('company_id', companyId)
.maybeSingle()
if (tokenRow?.user_id) {
await markNeedsReconsent(supabase, tokenRow.user_id as string, companyId, err.code)
// Same pick as resolveReadAuth above (a company can hold one row
// per connected member), so the flag lands on the row that failed.
const tokenRow = await findCompanyTokenUser(supabase, companyId)
if (tokenRow) {
await markNeedsReconsent(supabase, tokenRow.userId, companyId, err.code)
}
} catch (reconsentErr) {
console.warn('[vat-kvittenser-cron] Failed to persist reconsent flag', {