* 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>
223 lines
9.0 KiB
TypeScript
223 lines
9.0 KiB
TypeScript
import { redirect } from 'next/navigation'
|
|
import DashboardContent from '@/components/dashboard/DashboardContent'
|
|
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
|
|
import { listResumeItems } from '@/lib/worklist/resume'
|
|
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
|
|
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
|
import type { OnboardingProgress } from '@/types'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getResolvedDashboardAgentProfile,
|
|
} from './request-context'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// Home route = Hem (concept scene 14): greeting + Att göra + Fortsätt.
|
|
// The KPI/revenue/deadline widgets left the page (founder direction,
|
|
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
|
|
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
|
|
// queries are gone and the page got faster.
|
|
|
|
export default async function DashboardPage() {
|
|
const [{ supabase, user }, companyId] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
])
|
|
|
|
if (!user) {
|
|
redirect('/login')
|
|
}
|
|
|
|
if (!companyId) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
// Fetch all data in parallel
|
|
const [
|
|
settingsRes,
|
|
{ count: customerCount },
|
|
{ count: invoiceCount },
|
|
{ count: transactionCount },
|
|
{ data: bankConnections },
|
|
{ count: sieImportCount },
|
|
{ count: skatteverketTokenCount },
|
|
{ count: inboxItemCount },
|
|
{ count: postedEntryCount, error: postedEntryError },
|
|
{ data: nextVatDeadline },
|
|
{ data: profile },
|
|
agentProfile,
|
|
worklist,
|
|
suggestedMatches,
|
|
resumeItems,
|
|
otherAccountHint,
|
|
] = await Promise.all([
|
|
getDashboardSettings(),
|
|
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
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 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.
|
|
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
|
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
|
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
|
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
|
// Rows are system-generated per company settings; dismissed rows are
|
|
// excluded everywhere deadlines are listed, so here too.
|
|
supabase
|
|
.from('deadlines')
|
|
.select('due_date')
|
|
.eq('company_id', companyId)
|
|
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
|
.eq('is_completed', false)
|
|
.is('dismissed_at', null)
|
|
.gte('due_date', now.toISOString().slice(0, 10))
|
|
.order('due_date', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
// First name for the greeting.
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
|
getResolvedDashboardAgentProfile(),
|
|
// Pending-work counts + suggested matches come from lib/worklist: the
|
|
// same source as the sidebar badges, so the numbers can never diverge.
|
|
getWorklistCounts(supabase, companyId),
|
|
listSuggestedMatches(supabase, companyId, 5),
|
|
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
|
listResumeItems(supabase, companyId, now),
|
|
// Wrong-account hint (#1231): true only when this account has zero
|
|
// journal entries while a same-orgnr company with real bookkeeping
|
|
// exists in another account. Common case costs one existence probe.
|
|
shouldShowOtherAccountHint(supabase),
|
|
])
|
|
|
|
// A FAILED settings read must not masquerade as "onboarding not done":
|
|
// that sent fully onboarded users back to the wizard on a transient query
|
|
// failure (issue #1053). Throw to the error boundary (retryable) and only
|
|
// redirect on a genuinely incomplete or missing settings row.
|
|
const { data: settings, error: settingsError } = settingsRes
|
|
if (settingsError) {
|
|
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
|
|
}
|
|
|
|
// If onboarding is not complete, redirect to onboarding
|
|
if (!settings?.onboarding_complete) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
const agentBuilt = Boolean(agentProfile?.verified_at)
|
|
|
|
const onboardingProgress: OnboardingProgress = {
|
|
hasCustomers: (customerCount || 0) > 0,
|
|
hasInvoices: (invoiceCount || 0) > 0,
|
|
hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
|
|
hasSIEImport: (sieImportCount || 0) > 0,
|
|
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
|
hasInboxItems: (inboxItemCount || 0) > 0,
|
|
}
|
|
|
|
const vatLine = vatDeadlineLine({
|
|
vatRegistered: settings.vat_registered,
|
|
momsPeriod: settings.moms_period ?? null,
|
|
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
|
})
|
|
|
|
// "Empty ledger" only matters while the setup checklist is still open; once
|
|
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
|
// count must NOT read as empty: that would tell a company with real
|
|
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
|
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
|
const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
|
|
|
|
const nowMs = now.getTime()
|
|
const expiringBankConnections = (bankConnections || [])
|
|
.filter(conn => {
|
|
if (!conn.consent_expires) return false
|
|
const daysLeft = Math.ceil(
|
|
(new Date(conn.consent_expires).getTime() - nowMs) / (1000 * 60 * 60 * 24)
|
|
)
|
|
return daysLeft > 0 && daysLeft <= 14
|
|
})
|
|
.map(conn => ({
|
|
id: conn.id as string,
|
|
bank_name: conn.bank_name as string,
|
|
days_left: Math.ceil(
|
|
(new Date(conn.consent_expires!).getTime() - nowMs) / (1000 * 60 * 60 * 24)
|
|
),
|
|
}))
|
|
|
|
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
|
|
|
|
// Latest SIE reconciliation-sweep summary across both history sources
|
|
// (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import
|
|
// stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with
|
|
// "X matchade, Y att granska" so a migrator sees the sweep outcome without
|
|
// hunting for it. Best-effort: absent rows just render no note.
|
|
type SieSweepSummaryLite = {
|
|
auto_linked?: number
|
|
suggested?: number
|
|
unmatched?: number
|
|
errors?: number
|
|
ran_at?: string
|
|
}
|
|
const { data: latestFileSweep } = await supabase
|
|
.from('bank_file_imports')
|
|
.select('sie_sweep')
|
|
.eq('company_id', companyId)
|
|
.not('sie_sweep', 'is', null)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
const sweepCandidates: SieSweepSummaryLite[] = [
|
|
...(bankConnections || [])
|
|
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
|
|
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
|
|
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
|
|
]
|
|
const sieSweep =
|
|
sweepCandidates.length > 0
|
|
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
|
|
: null
|
|
|
|
return (
|
|
<DashboardContent
|
|
companyId={companyId}
|
|
agentBuilt={agentBuilt}
|
|
userFirstName={userFirstName}
|
|
expiringBankConnections={expiringBankConnections}
|
|
worklist={worklist}
|
|
suggestedMatches={suggestedMatches}
|
|
resumeItems={resumeItems}
|
|
otherAccountHint={otherAccountHint}
|
|
onboardingProgress={onboardingProgress}
|
|
initialSetup={{
|
|
path: settings.initial_setup_path ?? null,
|
|
completedAt: settings.initial_setup_completed_at ?? null,
|
|
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
|
}}
|
|
vatLine={vatLine}
|
|
emptyLedger={emptyLedger}
|
|
sieSweep={
|
|
sieSweep
|
|
? {
|
|
auto_linked: sieSweep.auto_linked ?? 0,
|
|
suggested: sieSweep.suggested ?? 0,
|
|
unmatched: sieSweep.unmatched ?? 0,
|
|
errors: sieSweep.errors ?? 0,
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
)
|
|
}
|