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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user