Files
accounted/components/dashboard/DashboardContent.tsx
T
MattssonandClaude Fable 5 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* 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>
2026-08-13 23:12:27 +02:00

208 lines
8.6 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { AttnLine } from '@/components/ui/attn-line'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useCapability, useCompany } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import AttGoraSection from '@/components/dashboard/AttGoraSection'
import ResumePane from '@/components/dashboard/ResumePane'
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
import { ArrowRight } from 'lucide-react'
import type { InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import type { ResumeItem } from '@/lib/worklist/resume'
import type { VatDeadlineLine } from '@/lib/onboarding/checklist'
interface DashboardContentProps {
companyId: string
/** Signed-in user's first name for the greeting; null falls back to a
* nameless greeting. */
userFirstName?: string | null
/** Expiring PSD2 consents (dashboard-only worklist extra). */
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
worklist: WorklistCounts
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
suggestedMatches: SuggestedMatch[]
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
resumeItems: ResumeItem[]
/**
* True when this account looks bookkeeping-empty while a same-orgnr
* company with real bookkeeping exists in another account (#1231): the
* user probably signed in with the wrong login (stale BankID account).
*/
otherAccountHint?: boolean
onboardingProgress?: OnboardingProgress
initialSetup: InitialSetupState
/**
* False until the company has a verified agent_profile. When false the hero
* slot shows a build-assistant prompt instead of the next-best-action card,
* so existing/migrated users are nudged to build the assistant without a
* full-screen onboarding takeover.
*/
agentBuilt?: boolean
/** Personalized VAT-deadline line for the checklist's Skatteverket step. */
vatLine?: VatDeadlineLine
/**
* True while the setup checklist is still open and the company has zero
* posted journal entries: Att göra's all-clear then reads as "empty, get
* started" instead of a false "all caught up".
*/
emptyLedger?: boolean
/** Latest SIE reconciliation-sweep outcome, for the checklist's bank step
* ("X matchade, Y att granska"). Null when no sweep has run. */
sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null
}
/**
* Hem (concept scene 14): greeting, then the two panes side by side:
* Att göra (obligations, lib/worklist) and Fortsätt (in-progress work,
* lib/worklist/resume). KPI tiles, revenue/expense cards and the deadline/tax
* widgets left the page (founder direction, dev_docs/last_session_resume.md
* §8): the numbers live at /kpi and /reports, deadlines render as Bevaka rows.
*/
export default function DashboardContent({
companyId,
userFirstName,
expiringBankConnections,
worklist,
suggestedMatches,
resumeItems,
otherAccountHint = false,
onboardingProgress,
initialSetup,
agentBuilt = true,
vatLine = null,
emptyLedger = false,
sieSweep = null,
}: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
const { company } = useCompany()
const router = useRouter()
// Wrong-account hint action: sign out so the user can come back in with
// their other login (email+password). Same flow as SandboxBanner.
async function handleSwitchAccount() {
const supabase = createClient()
await supabase.auth.signOut()
router.push('/login')
}
// Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock
// on purpose (the user's local morning, not the server's), captured once
// so render stays pure.
const [greetingNow] = useState(() => new Date())
const hour = greetingNow.getHours()
const greeting =
hour < 10 ? t('greeting_morning') : hour < 17 ? t('greeting_day') : t('greeting_evening')
const dateLine = new Intl.DateTimeFormat('sv-SE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(greetingNow)
return (
<div className="stagger-enter space-y-8">
<BackupHealthBanner />
{/* Greeting hero (concept scene 14) */}
<section>
<h1 className="font-display text-2xl leading-8 tracking-tight">
{userFirstName ? `${greeting}, ${userFirstName}.` : `${greeting}.`}
</h1>
<p className="mt-1.5 text-[13px] text-muted-foreground">
{dateLine}
{company?.name ? ` · ${company.name}` : ''}
</p>
{otherAccountHint && (
<AttnLine
className="mt-3"
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
>
{t('other_account_hint')}
</AttnLine>
)}
</section>
<NewUserChecklist
initialState={initialSetup}
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
hasBankConnected={!!onboardingProgress?.hasBankConnected}
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
hasInboxItems={!!onboardingProgress?.hasInboxItems}
hasAgentBuilt={agentBuilt}
vatLine={vatLine}
sieSweep={sieSweep}
/>
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
of a full-screen onboarding takeover. While the stepped first-run
checklist is visible it already carries the assistant as its last
step, so the hero waits until that block is dismissed or completed. */}
{!agentBuilt && (initialSetup.dismissedAt || initialSetup.completedAt) && (
<section>
{/* Non-payers keep seeing the hero (conversion surface) but it
routes to billing instead of a build flow that would 403. */}
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
<Card className="transition-colors hover:border-primary/50">
<CardContent className="p-6 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{hasAi
? 'Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.'
: 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig.'}
</p>
</div>
<div className="hidden sm:flex items-center gap-1.5 text-sm font-medium text-foreground group-hover:translate-x-0.5 transition-transform">
<span>{hasAi ? 'Kom igång' : 'Uppgradera'}</span>
<ArrowRight className="h-4 w-4" />
</div>
</CardContent>
</Card>
</Link>
</section>
)}
{/* The two panes (concept hem-grid). When nothing is in progress the
right pane renders null and Att göra takes the full width. */}
<div
className={
resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
}
>
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches}
expiringBankConnections={expiringBankConnections}
emptyLedger={emptyLedger}
/>
<ResumePane items={resumeItems} />
</div>
{/* Connect-Skatteverket nudge for existing companies. Gated on
agentBuilt so it never stacks under the build-assistant hero:
one CTA surface at a time. */}
{agentBuilt && (
<SkatteverketPromoCard
companyId={companyId}
connected={!!onboardingProgress?.hasSkatteverketConnected}
/>
)}
</div>
)
}