* feat(reconciliation): match migrated bank history against imported SIE verifikat A first-class Fortnox/SIE migrator path: after SIE import plus bank connect or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or suggestion-matched (0.75-0.89, persisted for review) against the imported verifikat, with a guided review surface, instead of landing as anonymous "Att bokfora" rows. Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account pooling); widen payment_match_log action CHECK with linked_to_existing_voucher (silently unlogged since March). Phase 1: potential_journal_entry_id/method/confidence on transactions with CHECK + invalidation triggers; persistSuggestions in runReconciliation; sweep after bank CSV import with SIE overlap (suppressing auto-categorization); sweep summaries stamped on bank_connections and bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with per-pair server-side revalidation (voucher consumption + bank-leg amount and direction). Phase 2: "Granska forslag" review tab on Transactions with chunked bulk confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode, mutually exclusive with dry_run), attn line, pre-migration row marker. Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant of the account-picker #917 nudge, sweep outcome on the onboarding checklist bank step. Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9 and persist the review band instead of auto-committing fuzzy matches. Migrations already applied to staging under the same versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): resolve PR review findings in one pass Swedish accounting review (both previously-deferred holes closed): - runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus event alone lands in the 30-day event_log and is not an audit record. - The three match-route storno-conflict branches detach reconciliation links via unlinkReconciliation instead of storno-reversing the linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). - Historical gap quantified on prod (read-only, recorded in DECISIONS): 762 unlogged manual links across 52 companies since 2026-03-23. CodeRabbit: - confirm-suggestions route: maxDuration 300 for full 500-item batches. - AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the async gap-fill probe cannot override an explicit choice. - enable-banking post-backfill sweep: persistSuggestions so the review band is not dropped. - bank-file execute: sie_sweep stamp errors are logged, not swallowed. - ImportResultStep: sandbox keeps the CSV CTA (file import works there). - payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan under ACCESS EXCLUSIVE. - logMatchEvent calls awaited (serverless can freeze unawaited work). - DECISIONS.md stale version reference annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): defer reconciliation-link detach until the match commits Round-2 review findings: - CodeRabbit: the eager unlinkReconciliation call could orphan a transaction if the match flow failed after it. All three match routes now persist NOTHING up front: the final transaction update overwrites journal_entry_id and clears reconciliation_method in the same write, so any failure in between leaves the existing link intact. The release is logged as 'unmatched' after the commit. - Swedish review: the auto_suggested logMatchEvent in runReconciliation is now awaited like every other audit write. - DECISIONS entry split into compliance/CodeRabbit lines and updated to describe the deferred detach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner The conditional spreads introduced with the deferred detach pushed the scanner's unresolvable-expression count past its ceiling (380 > 378). reconciliation_method: null is correct unconditionally on a confirmed invoice/supplier match (null is already the value on every row that was not reconciliation-linked), so the payloads become plain literals the guard can verify. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.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 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),
|
|
// 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
|
|
}
|
|
/>
|
|
)
|
|
}
|