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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
619b446c52
commit
1af5846adf
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user