feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1) Every class 1-2 account the bank and skattekonto adapters do not own now appears on the Avstämning page under "Övriga balanskonton" with IB, movement and UB through the balansdag, a system specification where one exists (1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld) and, for every other account, the balance the signer states from their underlag at sign-off. Same three doors as before: dashboard routes, v1 API and the MCP tools take manual:<BAS> keys and an external_balance. The ledger side is computed per fiscal period via generateTrialBalance, never as an all-history sum: year-end re-books every balance account in an opening_balance verifikat, so an all-history sum counts a closed year twice. A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever the system already has an outside truth, so it can never hide a difference. No migration: account_reconciliations already accepts manual:NNNN keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * chore(api-skill): regenerate banking reference for the sign-off external_balance field Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- 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
1e6e19afe9
commit
ebbdf96b74
@@ -21,7 +21,7 @@ import type {
|
||||
ReconciliationStatus,
|
||||
} from '@/lib/reconciliation/schemas'
|
||||
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
|
||||
import { SignoffDialog } from './SignoffDialog'
|
||||
import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog'
|
||||
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
|
||||
@@ -95,6 +95,10 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
const autorunDone = useRef(false)
|
||||
|
||||
const isSkv = account.kind === 'skattekonto'
|
||||
// Manual accounts have no rows to match or book: the body is the balance
|
||||
// bridge (IB, movement, UB against a specification or the signer's
|
||||
// underlag) and the sign-off.
|
||||
const isManual = account.kind === 'manual'
|
||||
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -251,7 +255,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSignoff(input: { through_date: string; note: string | null; force: boolean }) {
|
||||
async function submitSignoff(input: SignoffSubmitInput) {
|
||||
const res = await fetch(`${base}/signoff`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -405,6 +409,8 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
const asOfDate = status.as_of.slice(0, 10)
|
||||
const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at
|
||||
const sourceLabel = isSkv ? t('source_skv') : t('source_bank')
|
||||
const specification = isManual ? (status.manual?.specification ?? null) : null
|
||||
const specificationLabel = specification ? (locale === 'en' ? specification.label_en : specification.label_sv) : null
|
||||
|
||||
const bankRaw = status.bank as { bank_transaction_inflow?: number; bank_transaction_outflow?: number; bank_transaction_count?: number } | null
|
||||
const bankBreakdown =
|
||||
@@ -416,21 +422,34 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
})
|
||||
: null
|
||||
|
||||
const externalTile = isManual
|
||||
? {
|
||||
key: 'external',
|
||||
label: specificationLabel ?? t('tile_external_manual'),
|
||||
value: money(status.external_balance),
|
||||
sub: specification
|
||||
? t('tile_spec_today')
|
||||
: status.external_balance != null && status.signoff
|
||||
? t('tile_external_signed', { date: formatDate(status.signoff.through_date) })
|
||||
: t('tile_external_manual_sub'),
|
||||
}
|
||||
: {
|
||||
key: 'external',
|
||||
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
|
||||
// The bank tile is the period sum (what its label says), which lives on
|
||||
// the bridge; external_balance is the reported bank balance and is often
|
||||
// unknown, which rendered as "okänt" next to a bridge that knows better.
|
||||
value: money(
|
||||
isSkv
|
||||
? status.external_balance
|
||||
: (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance),
|
||||
),
|
||||
// Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched.
|
||||
sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')),
|
||||
}
|
||||
|
||||
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [
|
||||
{
|
||||
key: 'external',
|
||||
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
|
||||
// The bank tile is the period sum (what its label says), which lives on
|
||||
// the bridge; external_balance is the reported bank balance and is often
|
||||
// unknown, which rendered as "okänt" next to a bridge that knows better.
|
||||
value: money(
|
||||
isSkv
|
||||
? status.external_balance
|
||||
: (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance),
|
||||
),
|
||||
// Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched.
|
||||
sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')),
|
||||
},
|
||||
externalTile,
|
||||
{
|
||||
key: 'ledger',
|
||||
label: isSkv
|
||||
@@ -574,7 +593,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
{t('action_book_rows', { count: bookableIds.length })}
|
||||
</Button>
|
||||
)}
|
||||
{!isSkv && (
|
||||
{!isSkv && !isManual && (
|
||||
<Button size="sm" variant="outline" onClick={() => void runMatcher()} disabled={busy !== null} aria-busy={busy === 'matcher'}>
|
||||
{t('action_run_bank_matcher')}
|
||||
</Button>
|
||||
@@ -591,6 +610,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{isManual && (
|
||||
<span className="ml-auto">
|
||||
<Link href={`/reports/huvudbok?account=${encodeURIComponent(status.account_number)}`} className={QUIET_LINK_CLASS}>
|
||||
{t('action_open_ledger')}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.older_unmatched_count > 0 && (
|
||||
@@ -621,7 +647,11 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
)}
|
||||
|
||||
{/* The table: full width, banded by bucket, paired proposal rows. */}
|
||||
{items.items.length === 0 ? (
|
||||
{isManual ? (
|
||||
<p className="max-w-[560px] text-[13px] text-muted-foreground">
|
||||
{specificationLabel ? t('manual_spec_hint', { label: specificationLabel }) : t('manual_hint')}
|
||||
</p>
|
||||
) : items.items.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
|
||||
) : (
|
||||
<div className="-mx-4 overflow-x-auto sm:mx-0">
|
||||
@@ -730,6 +760,8 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
maxDate={signoffMaxDate}
|
||||
unexplained={status.unexplained_difference}
|
||||
currency={currency}
|
||||
askExternalBalance={isManual && !specification}
|
||||
ledgerBalance={status.ledger_balance}
|
||||
onSubmit={submitSignoff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
@@ -9,6 +10,8 @@ import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
|
||||
* The account rail of the Avstämning page: one row per account with an
|
||||
* outside truth (bank accounts, the skattekonto), logo or monogram, the
|
||||
* account number, when its outside side was last fetched, and a status dot.
|
||||
* The rest of the balance sheet follows under its own label: those accounts
|
||||
* have no feed, so their row says whether they are signed off instead.
|
||||
* Selection is URL-owned (?account=) by the workspace; the rail only reports.
|
||||
*/
|
||||
|
||||
@@ -40,15 +43,16 @@ export function AccountLogo({ account, className }: { account: ReconciliationAcc
|
||||
/>
|
||||
)
|
||||
}
|
||||
const label = account.kind === 'manual' ? account.account_number.slice(0, 2) : monogram(account.name)
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-sm bg-secondary text-[9px] font-semibold tracking-tight text-secondary-foreground',
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-sm bg-secondary text-[9px] font-semibold tracking-tight text-secondary-foreground tabular-nums',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{monogram(account.name)}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -61,68 +65,94 @@ interface ReconciliationRailProps {
|
||||
|
||||
export function ReconciliationRail({ accounts, selectedKey, onSelect }: ReconciliationRailProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const fed = accounts.filter((a) => a.kind !== 'manual')
|
||||
const manual = accounts.filter((a) => a.kind === 'manual')
|
||||
|
||||
const stateLabel = (account: ReconciliationAccount): string => {
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
if (account.kind === 'manual' && state === 'open') return t('state_unsigned')
|
||||
return t(`state_${state === 'unknown' ? 'not_configured' : state}`)
|
||||
}
|
||||
|
||||
const subLine = (account: ReconciliationAccount): string => {
|
||||
if (account.signed_off_through) return t('rail_signed_off', { date: formatDate(account.signed_off_through) })
|
||||
if (account.kind === 'manual') return t('rail_never_signed')
|
||||
const synced = account.source.synced_at
|
||||
return synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced')
|
||||
}
|
||||
|
||||
const renderRow = (account: ReconciliationAccount) => {
|
||||
const selected = account.account_key === selectedKey
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
const open = account.status
|
||||
? account.status.open_counts.proposed +
|
||||
account.status.open_counts.unmatched_external +
|
||||
account.status.open_counts.unmatched_ledger
|
||||
: 0
|
||||
return (
|
||||
<li key={account.account_key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(account.account_key)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors duration-150',
|
||||
selected ? 'bg-secondary' : 'hover:bg-muted/60',
|
||||
account.superseded_by && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<AccountLogo account={account} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-foreground" data-ph-mask>
|
||||
{account.name}
|
||||
</span>
|
||||
{account.superseded_by && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[10px] text-muted-foreground">
|
||||
{t('rail_superseded')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[11.5px] text-muted-foreground tabular-nums">
|
||||
<span data-ph-mask>{account.account_number}</span>
|
||||
{' · '}
|
||||
{subLine(account)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{open > 0 && (
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{open}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
aria-label={stateLabel(account)}
|
||||
title={stateLabel(account)}
|
||||
className={cn('h-2 w-2 rounded-full', DOT_CLASS[state])}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={t('rail_heading')} className="stagger-enter">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{accounts.map((account) => {
|
||||
const selected = account.account_key === selectedKey
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
const synced = account.source.synced_at
|
||||
const open = account.status
|
||||
? account.status.open_counts.proposed +
|
||||
account.status.open_counts.unmatched_external +
|
||||
account.status.open_counts.unmatched_ledger
|
||||
: 0
|
||||
return (
|
||||
<li key={account.account_key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(account.account_key)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors duration-150',
|
||||
selected ? 'bg-secondary' : 'hover:bg-muted/60',
|
||||
account.superseded_by && 'opacity-60',
|
||||
)}
|
||||
{fed.map(renderRow)}
|
||||
{manual.length > 0 && (
|
||||
<Fragment>
|
||||
{fed.length > 0 && (
|
||||
<li
|
||||
aria-hidden
|
||||
className="mt-3 px-3 pb-1 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground"
|
||||
>
|
||||
<AccountLogo account={account} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-foreground" data-ph-mask>
|
||||
{account.name}
|
||||
</span>
|
||||
{account.superseded_by && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[10px] text-muted-foreground">
|
||||
{t('rail_superseded')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[11.5px] text-muted-foreground tabular-nums">
|
||||
<span data-ph-mask>{account.account_number}</span>
|
||||
{' · '}
|
||||
{account.signed_off_through
|
||||
? t('rail_signed_off', { date: formatDate(account.signed_off_through) })
|
||||
: synced
|
||||
? t('rail_synced', { date: formatDate(synced) })
|
||||
: t('rail_never_synced')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{open > 0 && (
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{open}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
aria-label={t(`state_${state === 'unknown' ? 'not_configured' : state}`)}
|
||||
title={t(`state_${state === 'unknown' ? 'not_configured' : state}`)}
|
||||
className={cn('h-2 w-2 rounded-full', DOT_CLASS[state])}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{t('rail_group_manual')}
|
||||
</li>
|
||||
)}
|
||||
{manual.map(renderRow)}
|
||||
</Fragment>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -16,13 +16,24 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
/**
|
||||
* "Markera som avstämd": date, optional note, and (only when the engine
|
||||
* reports an unexplained difference) the explicit "sign anyway" choice that
|
||||
* makes the note mandatory. The policy lives in lib/reconciliation/signoff.ts;
|
||||
* this dialog only collects the input and shows the server's refusal verbatim.
|
||||
* makes the note mandatory. A manual account without a system specification
|
||||
* also asks for the balance per the signer's underlag; the difference against
|
||||
* the booked balance is then what needs explaining. The policy lives in
|
||||
* lib/reconciliation/signoff.ts; this dialog only collects the input and
|
||||
* shows the server's refusal verbatim.
|
||||
*/
|
||||
export interface SignoffSubmitInput {
|
||||
through_date: string
|
||||
note: string | null
|
||||
force: boolean
|
||||
external_balance?: number | null
|
||||
}
|
||||
|
||||
interface SignoffDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -33,8 +44,19 @@ interface SignoffDialogProps {
|
||||
maxDate: string
|
||||
unexplained: number | null
|
||||
currency: string
|
||||
/** Ask for the balance per underlag (manual accounts without a system specification). */
|
||||
askExternalBalance?: boolean
|
||||
/** The booked balance the stated one is compared with. */
|
||||
ledgerBalance?: number | null
|
||||
/** Returns an error message to show inline, or null on success. */
|
||||
onSubmit: (input: { through_date: string; note: string | null; force: boolean }) => Promise<string | null>
|
||||
onSubmit: (input: SignoffSubmitInput) => Promise<string | null>
|
||||
}
|
||||
|
||||
function parseAmount(raw: string): number | null {
|
||||
const normalized = raw.replace(/\s/g, '').replace(',', '.')
|
||||
if (normalized === '' || normalized === '-') return null
|
||||
const n = Number(normalized)
|
||||
return Number.isFinite(n) ? roundOre(n) : null
|
||||
}
|
||||
|
||||
export function SignoffDialog({
|
||||
@@ -45,16 +67,24 @@ export function SignoffDialog({
|
||||
maxDate,
|
||||
unexplained,
|
||||
currency,
|
||||
askExternalBalance = false,
|
||||
ledgerBalance = null,
|
||||
onSubmit,
|
||||
}: SignoffDialogProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const [date, setDate] = useState(defaultDate)
|
||||
const [note, setNote] = useState('')
|
||||
const [force, setForce] = useState(false)
|
||||
const [external, setExternal] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const needsForce = unexplained == null || Math.abs(unexplained) >= 0.005
|
||||
// With a stated balance the difference is against the booked balance;
|
||||
// without one the engine's number (or "unknown") decides.
|
||||
const stated = askExternalBalance ? parseAmount(external) : null
|
||||
const effectiveUnexplained =
|
||||
stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : unexplained
|
||||
const needsForce = effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005
|
||||
|
||||
// Reset per opening so a second sign-off does not inherit the last one's
|
||||
// note or override choice.
|
||||
@@ -63,6 +93,7 @@ export function SignoffDialog({
|
||||
setDate(defaultDate)
|
||||
setNote('')
|
||||
setForce(false)
|
||||
setExternal('')
|
||||
setError(null)
|
||||
}
|
||||
}, [open, defaultDate])
|
||||
@@ -73,7 +104,12 @@ export function SignoffDialog({
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const message = await onSubmit({ through_date: date, note: note.trim() || null, force: needsForce && force })
|
||||
const message = await onSubmit({
|
||||
through_date: date,
|
||||
note: note.trim() || null,
|
||||
force: needsForce && force,
|
||||
...(askExternalBalance ? { external_balance: stated } : {}),
|
||||
})
|
||||
if (message) setError(message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -99,12 +135,32 @@ export function SignoffDialog({
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
{askExternalBalance && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signoff-external">{t('signoff_external_balance')}</Label>
|
||||
<Input
|
||||
id="signoff-external"
|
||||
inputMode="decimal"
|
||||
value={external}
|
||||
onChange={(e) => setExternal(e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{ledgerBalance != null
|
||||
? t('signoff_external_balance_help', { amount: formatCurrency(ledgerBalance, currency) })
|
||||
: t('signoff_external_balance_optional')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{needsForce && (
|
||||
<div className="space-y-2 rounded-lg bg-warning/10 px-3 py-2.5 text-[13px] text-foreground">
|
||||
<p>
|
||||
{unexplained == null
|
||||
? t('tile_unknown')
|
||||
: t('signoff_unexplained_warning', { amount: formatCurrency(unexplained, currency) })}
|
||||
{effectiveUnexplained == null
|
||||
? askExternalBalance
|
||||
? t('signoff_external_balance_optional')
|
||||
: t('tile_unknown')
|
||||
: t('signoff_unexplained_warning', { amount: formatCurrency(effectiveUnexplained, currency) })}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-[13px]">
|
||||
<Checkbox checked={force} onCheckedChange={(v) => setForce(v === true)} />
|
||||
|
||||
Reference in New Issue
Block a user