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
@@ -1194,3 +1194,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
|
||||
[2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working.
|
||||
[2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger.
|
||||
[2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record.
|
||||
|
||||
@@ -11,6 +11,8 @@ const SignoffBodySchema = z.object({
|
||||
through_date: z.string().regex(ISO_DATE_RE),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
force: z.boolean().optional(),
|
||||
/** Manual accounts without a system specification: the balance per the signer's underlag, ledger sign. */
|
||||
external_balance: z.number().finite().nullable().optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -65,7 +67,12 @@ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }
|
||||
companyId,
|
||||
user.id,
|
||||
accountKey,
|
||||
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
|
||||
{
|
||||
through_date: parsed.data.through_date,
|
||||
note: parsed.data.note ?? null,
|
||||
force: parsed.data.force,
|
||||
external_balance: parsed.data.external_balance ?? null,
|
||||
},
|
||||
{ dryRun: parsed.data.dry_run === true },
|
||||
)
|
||||
if (!result) {
|
||||
|
||||
@@ -93,11 +93,39 @@ describe('dashboard sign-off routes', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined },
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null },
|
||||
{ dryRun: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('POST forwards a stated external_balance for a manual account and 400s a non-numeric one', async () => {
|
||||
const res = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: {
|
||||
through_date: '2026-07-31',
|
||||
external_balance: -250000,
|
||||
note: 'Enligt engagemangsbesked',
|
||||
} }),
|
||||
p({ accountKey: 'manual:2350' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(signMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'manual:2350',
|
||||
{ through_date: '2026-07-31', note: 'Enligt engagemangsbesked', force: undefined, external_balance: -250000 },
|
||||
{ dryRun: false },
|
||||
)
|
||||
const bad = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: {
|
||||
through_date: '2026-07-31',
|
||||
external_balance: '250 000',
|
||||
} }),
|
||||
p({ accountKey: 'manual:2350' }),
|
||||
)
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST 400s a missing or malformed through_date', async () => {
|
||||
const res = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '31/07/2026' } }),
|
||||
|
||||
+9
-3
@@ -24,6 +24,7 @@ const SignoffRequest = z.object({
|
||||
through_date: z.string().regex(ISO_DATE_RE),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
force: z.boolean().optional(),
|
||||
external_balance: z.number().finite().nullable().optional(),
|
||||
})
|
||||
|
||||
const SignoffResponse = z.object({
|
||||
@@ -89,11 +90,11 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff',
|
||||
summary: 'Mark an account reconciled through a date (sign-off).',
|
||||
description:
|
||||
'Body: { through_date: "YYYY-MM-DD", note?, force? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.',
|
||||
'Body: { through_date: "YYYY-MM-DD", note?, force?, external_balance? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). For a manual:NNNN account without a system specification (anything but 1510/2440/2920/2940), external_balance is the balance per the signer\'s underlag in ledger sign (liabilities negative); the difference against the booked balance is recorded, and a non-zero one still needs force + note. On bank, skattekonto and specification accounts external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.',
|
||||
useWhen: 'The month (or period) is explained and you want the account marked as reconciled through its last day, as a human would in the Avstämning page.',
|
||||
doNotUseFor: 'Linking rows or booking anything: a sign-off changes no data in the ledger. Use .../links and the booking endpoints first.',
|
||||
pitfalls: [
|
||||
'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.',
|
||||
'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED, EXTERNAL_BALANCE_NOT_ALLOWED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.',
|
||||
'force: true without a note is NOTE_REQUIRED: the note is what the next reader sees next to the non-zero difference.',
|
||||
'Idempotency-Key is required; repeating the same key replays the first response.',
|
||||
],
|
||||
@@ -169,7 +170,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
accountKey,
|
||||
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
|
||||
{
|
||||
through_date: parsed.data.through_date,
|
||||
note: parsed.data.note ?? null,
|
||||
force: parsed.data.force,
|
||||
external_balance: parsed.data.external_balance ?? null,
|
||||
},
|
||||
{ dryRun: ctx.dryRun },
|
||||
)
|
||||
if (!result) {
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ describe('v1 reconciliation sign-off', () => {
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined },
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null },
|
||||
{ dryRun: false },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('gnubok_reconcile_signoff', () => {
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: null, force: false },
|
||||
{ through_date: '2026-07-31', note: null, force: false, external_balance: null },
|
||||
{ dryRun: true },
|
||||
)
|
||||
expect(out).toMatchObject({ staged: false, dry_run: true, risk_level: 'medium' })
|
||||
|
||||
@@ -9875,14 +9875,14 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_reconciliation_status',
|
||||
title: 'Reconciliation Status',
|
||||
description: 'Reconciliation bridge for one account. Pass account_key ("skattekonto" or "bank:<cash_account_id>") for bridge lines + counts; without it, the legacy bank status for account_number (default 1930). Judge health on unexplained_difference, not difference.',
|
||||
description: 'Reconciliation bridge. account_key: "skattekonto", "bank:<cash_account_id>" or "manual:<BAS>" (any other balance account, see its manual block). Without it: legacy bank status for account_number. Judge on unexplained_difference.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
account_key: {
|
||||
type: 'string',
|
||||
description: '"skattekonto" or "bank:<cash_account_id>". When set, returns the account-keyed status (bridge[], counts, kind block).',
|
||||
description: '"skattekonto", "bank:<cash_account_id>" or "manual:<BAS>". Returns the account-keyed status (bridge[], counts, kind block); for manual keys date_to is the balansdag.',
|
||||
},
|
||||
date_from: { type: 'string', description: 'Start date YYYY-MM-DD' },
|
||||
date_to: { type: 'string', description: 'End date YYYY-MM-DD' },
|
||||
@@ -10136,16 +10136,17 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_reconcile_signoff',
|
||||
title: 'Reconcile: Sign off',
|
||||
description: 'Mark one account (skattekonto or bank:<cash_account_id>) as reconciled through a date ("avstämt t.o.m."). Refused unless unexplained_difference is 0 through that date, or force + note. Writes nothing to the ledger. Stages (medium risk); dry_run previews.',
|
||||
description: 'Mark one account (skattekonto, bank:<id> or manual:<BAS>) as reconciled through a date ("avstämt t.o.m."). Refused unless unexplained_difference is 0, or force + note. Manual accounts without a specification take external_balance (underlag, ledger sign). Stages; dry_run previews.',
|
||||
catalogVisibility: 'search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
account_key: { type: 'string', description: '"skattekonto" or "bank:<cash_account_id>".' },
|
||||
account_key: { type: 'string', description: '"skattekonto", "bank:<cash_account_id>" or "manual:<BAS>".' },
|
||||
through_date: { type: 'string', description: 'Inclusive YYYY-MM-DD the account is reconciled through (not in the future; not past the skattekonto snapshot).' },
|
||||
note: { type: 'string', description: 'Free text. Required with force.' },
|
||||
force: { type: 'boolean', description: 'Sign despite an unexplained difference or an unknown outside balance. Needs note.' },
|
||||
external_balance: { type: 'number', description: 'Manual accounts without a system specification only: balance per underlag, ledger sign (liabilities negative).' },
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
@@ -10169,7 +10170,12 @@ export const tools: McpTool[] = [
|
||||
companyId,
|
||||
userId,
|
||||
accountKey,
|
||||
{ through_date: throughDate, note: (args.note as string | undefined) ?? null, force: args.force === true },
|
||||
{
|
||||
through_date: throughDate,
|
||||
note: (args.note as string | undefined) ?? null,
|
||||
force: args.force === true,
|
||||
external_balance: typeof args.external_balance === 'number' ? args.external_balance : null,
|
||||
},
|
||||
{ dryRun: true },
|
||||
)
|
||||
if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`)
|
||||
@@ -10187,6 +10193,7 @@ export const tools: McpTool[] = [
|
||||
through_date: throughDate,
|
||||
note: (args.note as string | undefined) ?? null,
|
||||
force: args.force === true,
|
||||
external_balance: typeof args.external_balance === 'number' ? args.external_balance : null,
|
||||
},
|
||||
previewData,
|
||||
actor,
|
||||
|
||||
@@ -6070,6 +6070,7 @@ async function commitReconciliationSignoff(
|
||||
through_date: throughDate,
|
||||
note: (params.note as string | null | undefined) ?? null,
|
||||
force: params.force === true,
|
||||
external_balance: typeof params.external_balance === 'number' ? params.external_balance : null,
|
||||
},
|
||||
{ dryRun: false },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const trialBalanceMock = vi.fn()
|
||||
const arMock = vi.fn()
|
||||
const apMock = vi.fn()
|
||||
const vacationMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: (...args: unknown[]) => trialBalanceMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/reports/ar-reconciliation', () => ({
|
||||
generateARReconciliation: (...args: unknown[]) => arMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/reports/supplier-reconciliation', () => ({
|
||||
generateReconciliation: (...args: unknown[]) => apMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/reports/vacation-liability', () => ({
|
||||
generateVacationLiability: (...args: unknown[]) => vacationMock(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
buildManualStatus,
|
||||
getManualReconciliationStatus,
|
||||
listManualAccounts,
|
||||
loadBalanceSheetSnapshot,
|
||||
type BalanceSheetSnapshot,
|
||||
} from '../manual-reconciliation'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
function tbRow(
|
||||
account_number: string,
|
||||
account_name: string,
|
||||
account_class: number,
|
||||
opening: number,
|
||||
movement: number,
|
||||
) {
|
||||
const closing = opening + movement
|
||||
return {
|
||||
account_number,
|
||||
account_name,
|
||||
account_class,
|
||||
opening_debit: opening > 0 ? opening : 0,
|
||||
opening_credit: opening < 0 ? -opening : 0,
|
||||
period_debit: movement > 0 ? movement : 0,
|
||||
period_credit: movement < 0 ? -movement : 0,
|
||||
closing_debit: closing > 0 ? closing : 0,
|
||||
closing_credit: closing < 0 ? -closing : 0,
|
||||
}
|
||||
}
|
||||
|
||||
const TB_ROWS = [
|
||||
tbRow('1510', 'Kundfordringar', 1, 8000, 4000),
|
||||
tbRow('1930', 'Företagskonto', 1, 50000, -12000),
|
||||
tbRow('1630', 'Skattekonto', 1, 1200, 300),
|
||||
tbRow('2350', 'Banklån', 2, -260000, 10000),
|
||||
tbRow('2440', 'Leverantörsskulder', 2, -6000, -1500),
|
||||
tbRow('2920', 'Semesterlöneskuld', 2, -30000, -2000),
|
||||
tbRow('2990', 'Övriga upplupna kostnader', 2, 0, 0),
|
||||
tbRow('3001', 'Försäljning', 3, 0, -90000),
|
||||
]
|
||||
|
||||
function snapshot(): BalanceSheetSnapshot {
|
||||
const rows = new Map()
|
||||
for (const r of TB_ROWS) {
|
||||
if (r.account_class > 2) continue
|
||||
rows.set(r.account_number, {
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
opening_balance: r.opening_debit - r.opening_credit,
|
||||
movement: r.period_debit - r.period_credit,
|
||||
closing_balance: r.closing_debit - r.closing_credit,
|
||||
})
|
||||
}
|
||||
return { period: PERIOD, as_of: '2026-07-31', rows }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
trialBalanceMock.mockReset()
|
||||
arMock.mockReset()
|
||||
apMock.mockReset()
|
||||
vacationMock.mockReset()
|
||||
trialBalanceMock.mockResolvedValue({ rows: TB_ROWS })
|
||||
arMock.mockResolvedValue({ ar_ledger_total: 12000, account_1510_balance: 12000, difference: 0, is_reconciled: true, unconverted_fx_count: 0 })
|
||||
apMock.mockResolvedValue({ supplier_ledger_total: 7000, account_2440_balance: -7500, difference: -500, is_reconciled: false, unconverted_fx_count: 1 })
|
||||
vacationMock.mockResolvedValue({ rows: [], totals: { accruedAmount: 32000, accruedAvgifter: 10054, totalLiability: 42054 }, asOfDate: '2026-07-31' })
|
||||
})
|
||||
|
||||
describe('loadBalanceSheetSnapshot', () => {
|
||||
it('reads the period containing the date and the trial balance through it, class 1-2 only', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
const snap = await loadBalanceSheetSnapshot(supabase as never, COMPANY, '2026-07-31')
|
||||
expect(trialBalanceMock).toHaveBeenCalledWith(supabase, COMPANY, 'fy-2026', { closingEntry: 'include', toDate: '2026-07-31' })
|
||||
expect(snap?.rows.has('3001')).toBe(false)
|
||||
expect(snap?.rows.get('2350')).toEqual({
|
||||
account_number: '2350',
|
||||
account_name: 'Banklån',
|
||||
opening_balance: -260000,
|
||||
movement: 10000,
|
||||
closing_balance: -250000,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no fiscal period covers the date', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
expect(await loadBalanceSheetSnapshot(supabase as never, COMPANY, '2019-12-31')).toBeNull()
|
||||
expect(trialBalanceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildManualStatus', () => {
|
||||
it('compares a liability against its specification in ledger sign and carries the balances', () => {
|
||||
const snap = snapshot()
|
||||
const specs = new Map([['2440', { amount: -7000, unconverted_fx_count: 1 }]])
|
||||
const s = buildManualStatus(snap.rows.get('2440')!, snap, specs)
|
||||
expect(s).toMatchObject({
|
||||
account_key: 'manual:2440',
|
||||
kind: 'manual',
|
||||
as_of: '2026-07-31T00:00:00.000Z',
|
||||
external_balance: -7000,
|
||||
ledger_balance: -7500,
|
||||
difference: -500,
|
||||
unexplained_difference: -500,
|
||||
is_reconciled: false,
|
||||
manual: {
|
||||
period_id: 'fy-2026',
|
||||
opening_balance: -6000,
|
||||
movement: -1500,
|
||||
closing_balance: -7500,
|
||||
specification: { provider: 'ap', amount: -7000, unconverted_fx_count: 1 },
|
||||
},
|
||||
})
|
||||
expect(s.bridge.map((l) => l.key)).toEqual(['specification', 'opening_balance', 'movement', 'ledger_balance'])
|
||||
expect(s.bridge[0].label_sv).toMatch(/idag/)
|
||||
})
|
||||
|
||||
it('leaves the outside side unknown for an account without a specification', () => {
|
||||
const snap = snapshot()
|
||||
const s = buildManualStatus(snap.rows.get('2350')!, snap, new Map())
|
||||
expect(s).toMatchObject({ external_balance: null, difference: null, unexplained_difference: null, is_reconciled: false })
|
||||
expect(s.manual?.specification).toBeNull()
|
||||
expect(s.bridge.map((l) => l.key)).toEqual(['opening_balance', 'movement', 'ledger_balance'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getManualReconciliationStatus', () => {
|
||||
it('computes the reskontra specification only for its account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '1510', { asOf: '2026-07-31' })
|
||||
expect(s).toMatchObject({ external_balance: 12000, ledger_balance: 12000, difference: 0, is_reconciled: true })
|
||||
expect(arMock).toHaveBeenCalledWith(supabase, COMPANY, 'fy-2026')
|
||||
expect(apMock).not.toHaveBeenCalled()
|
||||
expect(vacationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a dormant balance account from the chart as a zero row, and refuses a result account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
enqueue({ data: { account_number: '2390', account_name: 'Övriga långfristiga skulder', account_class: 2 } })
|
||||
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '2390', { asOf: '2026-07-31' })
|
||||
expect(s).toMatchObject({ account_number: '2390', ledger_balance: 0, external_balance: null })
|
||||
|
||||
enqueue({ data: PERIOD })
|
||||
enqueue({ data: { account_number: '3001', account_name: 'Försäljning', account_class: 3 } })
|
||||
expect(await getManualReconciliationStatus(supabase as never, COMPANY, '3001', { asOf: '2026-07-31' })).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to an unknown outside side when a specification source fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
vacationMock.mockRejectedValue(new Error('salary module down'))
|
||||
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '2920', { asOf: '2026-07-31' })
|
||||
expect(s).toMatchObject({ external_balance: null, unexplained_difference: null })
|
||||
expect(s?.manual?.specification).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listManualAccounts', () => {
|
||||
it('lists balance accounts with a balance or movement, minus the fed ones, plus signed dormant ones, with states', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
const signoffs = new Map<string, { through_date: string } | null>([
|
||||
['manual:2350', { through_date: '2026-07-31' }],
|
||||
['manual:2990', { through_date: '2026-06-30' }],
|
||||
['bank:11111111-1111-4111-8111-111111111111', { through_date: '2026-07-31' }],
|
||||
])
|
||||
const accounts = await listManualAccounts(supabase as never, COMPANY, {
|
||||
asOf: '2026-07-31',
|
||||
exclude: new Set(['1930', '1630']),
|
||||
signoffs: signoffs as never,
|
||||
})
|
||||
expect(accounts.map((a) => a.account_number)).toEqual(['1510', '2350', '2440', '2920', '2990'])
|
||||
const byNo = Object.fromEntries(accounts.map((a) => [a.account_number, a]))
|
||||
// Specification agrees: reconciled without a sign-off.
|
||||
expect(byNo['1510'].status?.state).toBe('reconciled')
|
||||
// Specification differs by 500: open.
|
||||
expect(byNo['2440'].status).toMatchObject({ state: 'open', unexplained_difference: -500 })
|
||||
// No specification, but signed through the balansdag: reconciled.
|
||||
expect(byNo['2350']).toMatchObject({ signed_off_through: '2026-07-31', status: { state: 'reconciled' } })
|
||||
// Vacation liability from payroll: 2920 booked -32000 vs -32000 accrued.
|
||||
expect(byNo['2920'].status).toMatchObject({ state: 'reconciled', unexplained_difference: 0 })
|
||||
// Dormant but once signed: listed, not attested for this date.
|
||||
expect(byNo['2990']).toMatchObject({ signed_off_through: '2026-06-30', status: { state: 'open' } })
|
||||
expect(byNo['2990'].source).toEqual({ type: 'manual', synced_at: null, stale: false })
|
||||
// One trial balance read, one read per specification source.
|
||||
expect(trialBalanceMock).toHaveBeenCalledTimes(1)
|
||||
expect(arMock).toHaveBeenCalledTimes(1)
|
||||
expect(apMock).toHaveBeenCalledTimes(1)
|
||||
expect(vacationMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the specification reads when statuses are not wanted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: PERIOD })
|
||||
const accounts = await listManualAccounts(supabase as never, COMPANY, {
|
||||
asOf: '2026-07-31',
|
||||
exclude: new Set(),
|
||||
signoffs: new Map(),
|
||||
withStatus: false,
|
||||
})
|
||||
expect(accounts.every((a) => a.status === null)).toBe(true)
|
||||
expect(arMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns nothing when no period covers the date', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
expect(await listManualAccounts(supabase as never, COMPANY, { asOf: '2019-12-31', exclude: new Set(), signoffs: new Map() })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,12 @@ vi.mock('../skattekonto-reconciliation', () => ({
|
||||
vi.mock('../bank-reconciliation', () => ({
|
||||
getReconciliationStatus: (...args: unknown[]) => bankStatusMock(...args),
|
||||
}))
|
||||
const listManualMock = vi.fn()
|
||||
const manualStatusMock = vi.fn()
|
||||
vi.mock('../manual-reconciliation', () => ({
|
||||
listManualAccounts: (...args: unknown[]) => listManualMock(...args),
|
||||
getManualReconciliationStatus: (...args: unknown[]) => manualStatusMock(...args),
|
||||
}))
|
||||
|
||||
import { bankAccountKey, parseAccountKey } from '../schemas'
|
||||
import { getAccountStatus, listReconciliationAccounts } from '../service'
|
||||
@@ -75,6 +81,66 @@ describe('listReconciliationAccounts', () => {
|
||||
vi.clearAllMocks()
|
||||
skattekontoStatusMock.mockReset()
|
||||
bankStatusMock.mockReset()
|
||||
listManualMock.mockReset()
|
||||
listManualMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('appends the manual accounts after the fed ones, excluding the accounts the feeds own', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [cashAccount(ID_A, { is_primary: true }), cashAccount(ID_C, { ledger_account: '1931', iban: 'SE2' })] })
|
||||
enqueue({ data: [{ id: 's1', account_key: 'manual:2440', through_date: '2026-06-30', reopened_at: null }] }) // latest sign-offs
|
||||
enqueue({ data: [] }) // bank names for logos
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
skattekontoStatusMock.mockResolvedValue({
|
||||
account_key: 'skattekonto',
|
||||
kind: 'skattekonto',
|
||||
account_number: '1630',
|
||||
currency: 'SEK',
|
||||
as_of: '2026-08-20T04:00:00.000Z',
|
||||
stale: false,
|
||||
is_reconciled: true,
|
||||
unexplained_difference: 0,
|
||||
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 1, ignored: 0 },
|
||||
skattekonto: { fetched_at: '2026-08-20T04:00:00.000Z' },
|
||||
})
|
||||
listManualMock.mockResolvedValue([
|
||||
{ account_key: 'manual:1510', kind: 'manual', account_number: '1510', name: 'Kundfordringar' },
|
||||
{ account_key: 'manual:2440', kind: 'manual', account_number: '2440', name: 'Leverantörsskulder' },
|
||||
])
|
||||
|
||||
const accounts = await listReconciliationAccounts(supabase as never, COMPANY, {
|
||||
today: '2026-08-20',
|
||||
windowFrom: '2026-01-01',
|
||||
windowTo: '2026-07-31',
|
||||
withStatus: false,
|
||||
})
|
||||
|
||||
expect(accounts.map((a) => a.account_key)).toEqual([
|
||||
bankAccountKey(ID_A),
|
||||
bankAccountKey(ID_C),
|
||||
'skattekonto',
|
||||
'manual:1510',
|
||||
'manual:2440',
|
||||
])
|
||||
const [, , opts] = listManualMock.mock.calls[0] as [unknown, unknown, { asOf: string; exclude: Set<string>; withStatus: boolean; signoffs: Map<string, unknown> }]
|
||||
expect(opts.asOf).toBe('2026-07-31')
|
||||
expect([...opts.exclude].sort()).toEqual(['1630', '1930', '1931'])
|
||||
expect(opts.withStatus).toBe(false)
|
||||
expect(opts.signoffs.has('manual:2440')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fed accounts when the manual read fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [cashAccount(ID_A, { is_primary: true })] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
skattekontoStatusMock.mockResolvedValue(null)
|
||||
listManualMock.mockRejectedValue(new Error('trial balance down'))
|
||||
|
||||
const accounts = await listReconciliationAccounts(supabase as never, COMPANY, { today: '2026-08-20', withStatus: false })
|
||||
expect(accounts.map((a) => a.account_key)).toEqual([bankAccountKey(ID_A)])
|
||||
})
|
||||
|
||||
it('lists enabled cash accounts, folds reconnect duplicates by IBAN, and appends the skattekonto when configured', async () => {
|
||||
@@ -190,6 +256,69 @@ describe('getAccountStatus', () => {
|
||||
vi.clearAllMocks()
|
||||
skattekontoStatusMock.mockReset()
|
||||
bankStatusMock.mockReset()
|
||||
manualStatusMock.mockReset()
|
||||
})
|
||||
|
||||
function manualStatus(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
account_key: 'manual:2350',
|
||||
kind: 'manual',
|
||||
account_number: '2350',
|
||||
currency: 'SEK',
|
||||
as_of: '2026-07-31T00:00:00.000Z',
|
||||
stale: false,
|
||||
external_balance: null,
|
||||
ledger_balance: -250000,
|
||||
difference: null,
|
||||
unexplained_difference: null,
|
||||
is_reconciled: false,
|
||||
bridge: [],
|
||||
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
|
||||
skattekonto: null,
|
||||
bank: null,
|
||||
manual: { specification: null },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('dispatches manual keys to the manual adapter with the window end as the balansdag', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
manualStatusMock.mockResolvedValue(manualStatus())
|
||||
enqueue({ data: null }) // latest sign-off
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
|
||||
expect(manualStatusMock).toHaveBeenCalledWith(supabase, COMPANY, '2350', { today: '2026-08-20', asOf: '2026-07-31' })
|
||||
expect(s).toMatchObject({ account_key: 'manual:2350', external_balance: null, signoff: null })
|
||||
})
|
||||
|
||||
it('shows the stated balance from a sign-off made for the same balansdag as the outside side', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
manualStatusMock.mockResolvedValue(manualStatus())
|
||||
enqueue({
|
||||
data: {
|
||||
id: 's1',
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-07-31',
|
||||
external_balance: -250000,
|
||||
ledger_balance: -250000,
|
||||
unexplained_difference: 0,
|
||||
note: 'Enligt engagemangsbesked',
|
||||
signed_by: 'u1',
|
||||
signed_at: '2026-08-01T08:00:00Z',
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
reopen_reason: null,
|
||||
},
|
||||
})
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
|
||||
expect(s).toMatchObject({ external_balance: -250000, difference: 0, unexplained_difference: 0, is_reconciled: true })
|
||||
})
|
||||
|
||||
it('leaves the outside side unknown when the sign-off was for another date', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
manualStatusMock.mockResolvedValue(manualStatus())
|
||||
enqueue({ data: { id: 's1', account_key: 'manual:2350', through_date: '2026-06-30', external_balance: -260000, reopened_at: null } })
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
|
||||
expect(s).toMatchObject({ external_balance: null, unexplained_difference: null, is_reconciled: false })
|
||||
})
|
||||
|
||||
it('returns null for an invalid key and for an unknown cash account', async () => {
|
||||
|
||||
@@ -79,11 +79,129 @@ describe('signOffAccount', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an unknown or manual account key', async () => {
|
||||
it('returns null for an unknown account key and for a key the engine does not resolve', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
expect(await signOffAccount(supabase as never, COMPANY, USER, 'nope', { through_date: '2026-07-31' })).toBeNull()
|
||||
expect(await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' })).toBeNull()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
statusMock.mockResolvedValue(null)
|
||||
expect(
|
||||
await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).toBeNull()
|
||||
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'manual:1910', { today: TODAY, windowTo: '2026-07-31' })
|
||||
})
|
||||
|
||||
describe('manual accounts', () => {
|
||||
function manualStatus(overrides: Record<string, unknown> = {}) {
|
||||
return status({
|
||||
account_key: 'manual:2350',
|
||||
kind: 'manual',
|
||||
account_number: '2350',
|
||||
as_of: '2026-07-31T00:00:00.000Z',
|
||||
external_balance: null,
|
||||
ledger_balance: -250000,
|
||||
difference: null,
|
||||
unexplained_difference: null,
|
||||
is_reconciled: false,
|
||||
manual: { specification: null },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
it('signs with the stated balance when it equals the booked one, recording both', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(manualStatus())
|
||||
const result = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'manual:2350',
|
||||
{ through_date: '2026-07-31', external_balance: -250000, note: 'Enligt engagemangsbesked' },
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result).toMatchObject({ dry_run: false })
|
||||
expect(insertMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
expect.objectContaining({
|
||||
account_key: 'manual:2350',
|
||||
external_balance: -250000,
|
||||
ledger_balance: -250000,
|
||||
unexplained_difference: 0,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats the gap between stated and booked as the unexplained difference', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(manualStatus())
|
||||
await expect(
|
||||
signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'manual:2350',
|
||||
{ through_date: '2026-07-31', external_balance: -249500 },
|
||||
{ today: TODAY },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'NOT_RECONCILED' })
|
||||
const forced = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'manual:2350',
|
||||
{ through_date: '2026-07-31', external_balance: -249500, force: true, note: 'Amortering bokförs i augusti.' },
|
||||
{ today: TODAY, dryRun: true },
|
||||
)
|
||||
expect(forced).toMatchObject({
|
||||
dry_run: true,
|
||||
would_sign: { external_balance: -249500, ledger_balance: -250000, unexplained_difference: -500, forced: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('still needs force + note when no balance is stated', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(manualStatus())
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'manual:2350', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'OUTSIDE_UNKNOWN' })
|
||||
})
|
||||
|
||||
it('refuses a stated balance where the system already has an outside truth', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(
|
||||
manualStatus({
|
||||
account_key: 'manual:1510',
|
||||
account_number: '1510',
|
||||
external_balance: 12000,
|
||||
ledger_balance: 12000,
|
||||
unexplained_difference: 0,
|
||||
is_reconciled: true,
|
||||
manual: { specification: { provider: 'ar', amount: 12000 } },
|
||||
}),
|
||||
)
|
||||
await expect(
|
||||
signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'manual:1510',
|
||||
{ through_date: '2026-07-31', external_balance: 12000 },
|
||||
{ today: TODAY },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
|
||||
statusMock.mockResolvedValue(status())
|
||||
await expect(
|
||||
signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', external_balance: 1000 },
|
||||
{ today: TODAY },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
|
||||
expect(insertMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed date and a date in the future', async () => {
|
||||
|
||||
@@ -102,6 +102,12 @@ export async function listAccountItems(
|
||||
const limit = clampLimit(options.limit)
|
||||
const offset = Math.max(0, Math.floor(options.offset ?? 0))
|
||||
|
||||
// A manual account has no external rows to bucket: its bridge is IB,
|
||||
// movement and UB against a specification or the signer's underlag.
|
||||
if (parsed.kind === 'manual') {
|
||||
return { items: [], count: 0, total_count: 0, has_more: false, older_unmatched_count: 0 }
|
||||
}
|
||||
|
||||
if (parsed.kind === 'skattekonto') {
|
||||
const status = await getSkattekontoReconciliationStatus(supabase, companyId, {
|
||||
today: options.today,
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||
import { generateReconciliation as generateSupplierReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
|
||||
import {
|
||||
manualAccountKey,
|
||||
type BridgeLine,
|
||||
type ManualSpecification,
|
||||
type ReconciliationAccount,
|
||||
type ReconciliationSignoff,
|
||||
type ReconciliationStatus,
|
||||
} from './schemas'
|
||||
|
||||
const log = createLogger('reconciliation/manual')
|
||||
|
||||
/**
|
||||
* The manual adapter: every balance sheet account that has no feed of its
|
||||
* own (no bank connection, no Skatteverket sync) but still has to be
|
||||
* reconciled and attested for a bokslut (Reko 760/765: each material
|
||||
* balanspost against its underlag, documented and signed).
|
||||
*
|
||||
* The ledger side is the account's balance on the balansdag, computed the way
|
||||
* the trial balance computes it: the fiscal period's opening balance plus the
|
||||
* movement through the date. It is never an all-history sum: year-end posts
|
||||
* an opening-balance verifikat in the new year that re-books every balance
|
||||
* account, so summing across periods counts a closed year twice.
|
||||
*
|
||||
* The outside side comes from a specification the system already keeps
|
||||
* (kundreskontra for 1510, leverantörsreskontra for 2440, semesterlöneskuld
|
||||
* for 2920/2940) or, for every other account, from the balance the signer
|
||||
* states from their underlag when they sign off. The reskontra totals are the
|
||||
* open items as they stand today, not as of the balansdag; a per-date
|
||||
* reskontra is a follow-up and the bridge label says "idag" until then.
|
||||
*/
|
||||
|
||||
export const BALANCE_TOLERANCE = 0.005
|
||||
|
||||
export interface BalanceRow {
|
||||
account_number: string
|
||||
account_name: string
|
||||
opening_balance: number
|
||||
movement: number
|
||||
closing_balance: number
|
||||
}
|
||||
|
||||
export interface BalanceSheetSnapshot {
|
||||
period: { id: string; name: string; period_start: string; period_end: string }
|
||||
/** The balansdag the balances are computed through (inclusive). */
|
||||
as_of: string
|
||||
rows: Map<string, BalanceRow>
|
||||
}
|
||||
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Class 1-2 balances through `asOfDate`, from the fiscal period that contains
|
||||
* the date. Null when no period covers the date (nothing to reconcile there).
|
||||
*/
|
||||
export async function loadBalanceSheetSnapshot(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
asOfDate: string,
|
||||
): Promise<BalanceSheetSnapshot | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', asOfDate)
|
||||
.gte('period_end', asOfDate)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta räkenskapsår: ${error.message}`)
|
||||
const period = data as FiscalPeriodRow | null
|
||||
if (!period) return null
|
||||
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, period.id, {
|
||||
closingEntry: 'include',
|
||||
toDate: asOfDate,
|
||||
})
|
||||
const map = new Map<string, BalanceRow>()
|
||||
for (const r of rows) {
|
||||
if (r.account_class !== 1 && r.account_class !== 2) continue
|
||||
map.set(r.account_number, {
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
opening_balance: roundOre(r.opening_debit - r.opening_credit),
|
||||
movement: roundOre(r.period_debit - r.period_credit),
|
||||
closing_balance: roundOre(r.closing_debit - r.closing_credit),
|
||||
})
|
||||
}
|
||||
return { period, as_of: asOfDate, rows: map }
|
||||
}
|
||||
|
||||
/** A system-kept specification for one balance account, in ledger sign (debit positive). */
|
||||
export interface SpecificationProvider {
|
||||
key: ManualSpecification['provider']
|
||||
label_sv: string
|
||||
label_en: string
|
||||
}
|
||||
|
||||
export const SPECIFICATION_PROVIDERS: Record<string, SpecificationProvider> = {
|
||||
'1510': { key: 'ar', label_sv: 'Kundreskontra, öppna fakturor', label_en: 'Customer ledger, open invoices' },
|
||||
'2440': { key: 'ap', label_sv: 'Leverantörsreskontra, öppna fakturor', label_en: 'Supplier ledger, open invoices' },
|
||||
'2920': { key: 'vacation', label_sv: 'Semesterlöneskuld enligt lönekörningar', label_en: 'Vacation liability per payroll runs' },
|
||||
'2940': {
|
||||
key: 'vacation',
|
||||
label_sv: 'Sociala avgifter på semesterlöneskuld enligt lönekörningar',
|
||||
label_en: 'Social fees on vacation liability per payroll runs',
|
||||
},
|
||||
}
|
||||
|
||||
export type SpecificationAmounts = Map<string, { amount: number; unconverted_fx_count: number }>
|
||||
|
||||
/**
|
||||
* Compute the specification amounts for the provider accounts, one read per
|
||||
* source (the two reskontra tie-outs and the vacation liability). A failed
|
||||
* source is logged and left out: the account then reconciles like any manual
|
||||
* account (outside unknown) rather than against a wrong number.
|
||||
*/
|
||||
export async function loadSpecificationAmounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
onlyAccounts?: ReadonlySet<string>,
|
||||
): Promise<SpecificationAmounts> {
|
||||
const out: SpecificationAmounts = new Map()
|
||||
const wants = (n: string) => !onlyAccounts || onlyAccounts.has(n)
|
||||
const tasks: Array<Promise<void>> = []
|
||||
|
||||
if (wants('1510')) {
|
||||
tasks.push(
|
||||
generateARReconciliation(supabase, companyId, snapshot.period.id)
|
||||
.then((r) => {
|
||||
out.set('1510', { amount: roundOre(r.ar_ledger_total), unconverted_fx_count: r.unconverted_fx_count })
|
||||
})
|
||||
.catch((err) => log.warn('AR specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
if (wants('2440')) {
|
||||
tasks.push(
|
||||
generateSupplierReconciliation(supabase, companyId, snapshot.period.id)
|
||||
.then((r) => {
|
||||
// Liabilities carry credit balances: negative in ledger sign.
|
||||
out.set('2440', { amount: roundOre(-r.supplier_ledger_total), unconverted_fx_count: r.unconverted_fx_count })
|
||||
})
|
||||
.catch((err) => log.warn('AP specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
if (wants('2920') || wants('2940')) {
|
||||
const year = Number(snapshot.as_of.slice(0, 4))
|
||||
tasks.push(
|
||||
generateVacationLiability(supabase, companyId, year)
|
||||
.then((r) => {
|
||||
if (wants('2920')) out.set('2920', { amount: roundOre(-r.totals.accruedAmount), unconverted_fx_count: 0 })
|
||||
if (wants('2940')) out.set('2940', { amount: roundOre(-r.totals.accruedAvgifter), unconverted_fx_count: 0 })
|
||||
})
|
||||
.catch((err) => log.warn('vacation specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
return out
|
||||
}
|
||||
|
||||
function bridgeFor(
|
||||
row: BalanceRow,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
specification: ManualSpecification | null,
|
||||
): BridgeLine[] {
|
||||
const lines: BridgeLine[] = []
|
||||
if (specification) {
|
||||
lines.push({
|
||||
key: 'specification',
|
||||
label_sv: `${specification.label_sv} (idag)`,
|
||||
label_en: `${specification.label_en} (today)`,
|
||||
amount: specification.amount,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
})
|
||||
}
|
||||
lines.push(
|
||||
{
|
||||
key: 'opening_balance',
|
||||
label_sv: `Ingående balans ${snapshot.period.period_start}`,
|
||||
label_en: `Opening balance ${snapshot.period.period_start}`,
|
||||
amount: row.opening_balance,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
{
|
||||
key: 'movement',
|
||||
label_sv: `Förändring t.o.m. ${snapshot.as_of}`,
|
||||
label_en: `Movement through ${snapshot.as_of}`,
|
||||
amount: row.movement,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
{
|
||||
key: 'ledger_balance',
|
||||
label_sv: `Bokfört på ${row.account_number} per ${snapshot.as_of}`,
|
||||
label_en: `Booked on ${row.account_number} as of ${snapshot.as_of}`,
|
||||
amount: row.closing_balance,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Status from an already loaded snapshot and specification map (no reads). */
|
||||
export function buildManualStatus(
|
||||
row: BalanceRow,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
specifications: SpecificationAmounts,
|
||||
): ReconciliationStatus {
|
||||
const provider = SPECIFICATION_PROVIDERS[row.account_number]
|
||||
const spec = provider ? specifications.get(row.account_number) : undefined
|
||||
const specification: ManualSpecification | null =
|
||||
provider && spec
|
||||
? {
|
||||
provider: provider.key,
|
||||
label_sv: provider.label_sv,
|
||||
label_en: provider.label_en,
|
||||
amount: spec.amount,
|
||||
unconverted_fx_count: spec.unconverted_fx_count,
|
||||
}
|
||||
: null
|
||||
const external = specification ? specification.amount : null
|
||||
const difference = external == null ? null : roundOre(row.closing_balance - external)
|
||||
return {
|
||||
account_key: manualAccountKey(row.account_number),
|
||||
kind: 'manual',
|
||||
account_number: row.account_number,
|
||||
currency: 'SEK',
|
||||
window: { from: snapshot.period.period_start, to: snapshot.as_of },
|
||||
// The balansdag itself: the tiles read "per <date>" from it.
|
||||
as_of: `${snapshot.as_of}T00:00:00.000Z`,
|
||||
stale: false,
|
||||
external_balance: external,
|
||||
ledger_balance: row.closing_balance,
|
||||
difference,
|
||||
// Nothing explains a manual difference but the signer's note.
|
||||
unexplained_difference: difference,
|
||||
is_reconciled: difference != null && Math.abs(difference) < BALANCE_TOLERANCE,
|
||||
bridge: bridgeFor(row, snapshot, specification),
|
||||
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
|
||||
skattekonto: null,
|
||||
bank: null,
|
||||
manual: {
|
||||
period_id: snapshot.period.id,
|
||||
period_start: snapshot.period.period_start,
|
||||
period_end: snapshot.period.period_end,
|
||||
opening_balance: row.opening_balance,
|
||||
movement: row.movement,
|
||||
closing_balance: row.closing_balance,
|
||||
specification,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
): Promise<BalanceRow | null> {
|
||||
const row = snapshot.rows.get(accountNumber)
|
||||
if (row) return row
|
||||
// No activity in the period: the account is still reconcilable (a zero
|
||||
// balance is a claim too) as long as it exists in the company's chart.
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountNumber)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta konto: ${error.message}`)
|
||||
const account = data as { account_number: string; account_name: string; account_class: number } | null
|
||||
if (!account || (account.account_class !== 1 && account.account_class !== 2)) return null
|
||||
return {
|
||||
account_number: account.account_number,
|
||||
account_name: account.account_name,
|
||||
opening_balance: 0,
|
||||
movement: 0,
|
||||
closing_balance: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManualStatusOptions {
|
||||
/** The balansdag. Defaults to today. */
|
||||
asOf?: string | null
|
||||
today?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Status for one manual account. Null when the date falls outside every
|
||||
* fiscal period or the account is not a balance account of this company.
|
||||
*/
|
||||
export async function getManualReconciliationStatus(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string,
|
||||
options: ManualStatusOptions = {},
|
||||
): Promise<ReconciliationStatus | null> {
|
||||
const today = options.today ?? new Date().toISOString().slice(0, 10)
|
||||
const asOf = options.asOf ?? today
|
||||
const snapshot = await loadBalanceSheetSnapshot(supabase, companyId, asOf)
|
||||
if (!snapshot) return null
|
||||
const row = await resolveRow(supabase, companyId, accountNumber, snapshot)
|
||||
if (!row) return null
|
||||
const specifications = SPECIFICATION_PROVIDERS[accountNumber]
|
||||
? await loadSpecificationAmounts(supabase, companyId, snapshot, new Set([accountNumber]))
|
||||
: new Map()
|
||||
return buildManualStatus(row, snapshot, specifications)
|
||||
}
|
||||
|
||||
export interface ListManualAccountsOptions {
|
||||
/** The balansdag the list is computed through. */
|
||||
asOf: string
|
||||
/** Account numbers other adapters already own (cash accounts' ledger accounts, 1630 when the skattekonto is present). */
|
||||
exclude: ReadonlySet<string>
|
||||
/** Latest active sign-off per account key, as the service already loaded them. */
|
||||
signoffs: ReadonlyMap<string, ReconciliationSignoff | null>
|
||||
withStatus?: boolean
|
||||
}
|
||||
|
||||
function manualState(
|
||||
status: ReconciliationStatus,
|
||||
signedOffThrough: string | null,
|
||||
asOf: string,
|
||||
): NonNullable<ReconciliationAccount['status']> {
|
||||
// A manual account has no live feed, so the attestation is its state: signed
|
||||
// through the balansdag counts as reconciled; otherwise the specification
|
||||
// decides, and an account without one is simply not reconciled yet.
|
||||
const attested = signedOffThrough != null && signedOffThrough >= asOf
|
||||
const state: NonNullable<ReconciliationAccount['status']>['state'] =
|
||||
attested || status.is_reconciled ? 'reconciled' : 'open'
|
||||
return {
|
||||
state,
|
||||
as_of: status.as_of,
|
||||
unexplained_difference: status.unexplained_difference,
|
||||
open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every balance account with a balance or a movement through the date, plus
|
||||
* any account that carries a sign-off, minus the accounts other adapters own.
|
||||
* One trial balance read for the balances; one read per specification source.
|
||||
*/
|
||||
export async function listManualAccounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: ListManualAccountsOptions,
|
||||
): Promise<ReconciliationAccount[]> {
|
||||
const snapshot = await loadBalanceSheetSnapshot(supabase, companyId, options.asOf)
|
||||
if (!snapshot) return []
|
||||
const withStatus = options.withStatus ?? true
|
||||
|
||||
const candidates = new Map<string, BalanceRow>()
|
||||
for (const row of snapshot.rows.values()) {
|
||||
if (options.exclude.has(row.account_number)) continue
|
||||
if (Math.abs(row.closing_balance) < BALANCE_TOLERANCE && Math.abs(row.movement) < BALANCE_TOLERANCE) continue
|
||||
candidates.set(row.account_number, row)
|
||||
}
|
||||
for (const [key, signoff] of options.signoffs) {
|
||||
if (!signoff || !key.startsWith('manual:')) continue
|
||||
const accountNumber = key.slice('manual:'.length)
|
||||
if (options.exclude.has(accountNumber) || candidates.has(accountNumber)) continue
|
||||
const row = snapshot.rows.get(accountNumber) ?? {
|
||||
account_number: accountNumber,
|
||||
account_name: `Konto ${accountNumber}`,
|
||||
opening_balance: 0,
|
||||
movement: 0,
|
||||
closing_balance: 0,
|
||||
}
|
||||
candidates.set(accountNumber, row)
|
||||
}
|
||||
|
||||
const providerAccounts = new Set([...candidates.keys()].filter((n) => SPECIFICATION_PROVIDERS[n]))
|
||||
const specifications =
|
||||
withStatus && providerAccounts.size > 0
|
||||
? await loadSpecificationAmounts(supabase, companyId, snapshot, providerAccounts)
|
||||
: new Map()
|
||||
|
||||
return [...candidates.values()]
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
.map((row): ReconciliationAccount => {
|
||||
const key = manualAccountKey(row.account_number)
|
||||
const signedOffThrough = options.signoffs.get(key)?.through_date ?? null
|
||||
const status = withStatus ? buildManualStatus(row, snapshot, specifications) : null
|
||||
return {
|
||||
account_key: key,
|
||||
kind: 'manual',
|
||||
account_number: row.account_number,
|
||||
name: row.account_name,
|
||||
currency: 'SEK',
|
||||
logo_url: null,
|
||||
source: { type: 'manual', synced_at: null, stale: false },
|
||||
status: status ? manualState(status, signedOffThrough, options.asOf) : null,
|
||||
superseded_by: null,
|
||||
signed_off_through: signedOffThrough,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { z } from 'zod'
|
||||
* Account keys:
|
||||
* bank:<cash_account_id> one cash_accounts row (PSD2 or file-fed)
|
||||
* skattekonto the company's Skatteverket tax account (BAS 1630)
|
||||
* manual:<account_number> later: accounts with a typed external balance
|
||||
* manual:<account_number> any other balance account: reconciled against a
|
||||
* system specification (reskontra, semesterskuld)
|
||||
* or the balance the signer states from underlag
|
||||
*/
|
||||
export const ACCOUNT_KEY_REGEX =
|
||||
/^(bank:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|skattekonto|manual:\d{4})$/
|
||||
@@ -42,6 +44,10 @@ export function bankAccountKey(cashAccountId: string): string {
|
||||
|
||||
export const SKATTEKONTO_ACCOUNT_KEY = 'skattekonto' as const
|
||||
|
||||
export function manualAccountKey(accountNumber: string): string {
|
||||
return `manual:${accountNumber}`
|
||||
}
|
||||
|
||||
export const ReconciliationSourceSchema = z.object({
|
||||
type: z.enum(['psd2', 'bank_file', 'skatteverket_api', 'skatteverket_file', 'manual']),
|
||||
/** ISO timestamp of the last successful sync / import; null when never. */
|
||||
@@ -183,6 +189,29 @@ export const SkattekontoStatusBlockSchema = z.object({
|
||||
ledger_balance_before_start: z.number().nullable(),
|
||||
})
|
||||
|
||||
/** A specification the system keeps for a manual account, in ledger sign (debit positive). */
|
||||
export const ManualSpecificationSchema = z.object({
|
||||
provider: z.enum(['ar', 'ap', 'vacation']),
|
||||
label_sv: z.string(),
|
||||
label_en: z.string(),
|
||||
amount: z.number(),
|
||||
/** Foreign-currency rows left out for lack of a rate (see lib/reports/ar-reconciliation.ts). */
|
||||
unconverted_fx_count: z.number().int(),
|
||||
})
|
||||
export type ManualSpecification = z.infer<typeof ManualSpecificationSchema>
|
||||
|
||||
export const ManualStatusBlockSchema = z.object({
|
||||
period_id: z.string(),
|
||||
period_start: z.string(),
|
||||
period_end: z.string(),
|
||||
/** IB, movement and UB through the balansdag, debit positive (lib/reconciliation/manual-reconciliation.ts). */
|
||||
opening_balance: z.number(),
|
||||
movement: z.number(),
|
||||
closing_balance: z.number(),
|
||||
/** Null for accounts whose outside balance the signer states at sign-off. */
|
||||
specification: ManualSpecificationSchema.nullable(),
|
||||
})
|
||||
|
||||
export const ReconciliationStatusSchema = z.object({
|
||||
account_key: AccountKeySchema,
|
||||
kind: ReconciliationKindSchema,
|
||||
@@ -209,6 +238,8 @@ export const ReconciliationStatusSchema = z.object({
|
||||
skattekonto: SkattekontoStatusBlockSchema.nullable(),
|
||||
/** Today's bank status fields, unchanged, for the bank kind (see bank-reconciliation.ts). */
|
||||
bank: z.record(z.string(), z.unknown()).nullable(),
|
||||
/** Balances and specification for the manual kind; absent on the other kinds. */
|
||||
manual: ManualStatusBlockSchema.nullable().optional(),
|
||||
/** Latest active sign-off on the account (lib/reconciliation/signoff.ts); null when none. */
|
||||
signoff: ReconciliationSignoffSchema.nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './schemas'
|
||||
import { getLatestSignoff, getLatestSignoffs } from './signoff-store'
|
||||
import { bankLogoUrl } from './bank-logos'
|
||||
import { getManualReconciliationStatus, listManualAccounts } from './manual-reconciliation'
|
||||
|
||||
const log = createLogger('reconciliation/service')
|
||||
|
||||
@@ -22,9 +23,10 @@ const log = createLogger('reconciliation/service')
|
||||
*
|
||||
* The dashboard routes, the public v1 API and the MCP tools all call these
|
||||
* functions; none of them re-implements bank or skattekonto logic. Kind
|
||||
* adapters (bank today via bank-reconciliation.ts, skattekonto via
|
||||
* skattekonto-reconciliation.ts, manual later) hang off `account_key`, so
|
||||
* adding an account type is one adapter, never a new set of endpoints.
|
||||
* adapters (bank via bank-reconciliation.ts, skattekonto via
|
||||
* skattekonto-reconciliation.ts, every other balance account via
|
||||
* manual-reconciliation.ts) hang off `account_key`, so adding an account
|
||||
* type is one adapter, never a new set of endpoints.
|
||||
*
|
||||
* Core runs with zero extensions: the skattekonto adapter reads the core
|
||||
* `skattekonto_transactions` table and the snapshot row the extension leaves
|
||||
@@ -338,7 +340,24 @@ export async function listReconciliationAccounts(
|
||||
})
|
||||
}
|
||||
|
||||
return skattekonto ? [...bankAccounts, skattekonto] : bankAccounts
|
||||
// The rest of the balance sheet: every account the two feeds above do not
|
||||
// own, reconciled against a system specification or the signer's underlag.
|
||||
// A failed read costs only this group, never the bank or skattekonto rows.
|
||||
let manualAccounts: ReconciliationAccount[] = []
|
||||
try {
|
||||
const exclude = new Set<string>(cashAccounts.map((a) => a.ledger_account))
|
||||
if (skattekonto) exclude.add(skattekonto.account_number)
|
||||
manualAccounts = await listManualAccounts(supabase, companyId, {
|
||||
asOf: window.to,
|
||||
exclude,
|
||||
signoffs,
|
||||
withStatus,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('manual accounts failed', { companyId, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
return [...bankAccounts, ...(skattekonto ? [skattekonto] : []), ...manualAccounts]
|
||||
}
|
||||
|
||||
export interface GetAccountStatusOptions {
|
||||
@@ -385,7 +404,12 @@ export async function getAccountStatus(
|
||||
}
|
||||
status = await bankStatus(supabase, companyId, data as CashAccountRow, window, today)
|
||||
}
|
||||
// manual accounts: later adapter
|
||||
if (parsed.kind === 'manual') {
|
||||
status = await getManualReconciliationStatus(supabase, companyId, parsed.accountNumber, {
|
||||
today,
|
||||
asOf: options.windowTo ?? today,
|
||||
})
|
||||
}
|
||||
if (!status) return null
|
||||
|
||||
// The latest active sign-off rides along on every status read (page, v1,
|
||||
@@ -396,5 +420,23 @@ export async function getAccountStatus(
|
||||
log.warn('sign-off read failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
|
||||
status.signoff = null
|
||||
}
|
||||
|
||||
// A manual account without a system specification has no live outside
|
||||
// balance; the one the signer stated for this very balansdag is the
|
||||
// attested truth, so the status shows it instead of "okänt".
|
||||
if (
|
||||
status.kind === 'manual' &&
|
||||
status.external_balance == null &&
|
||||
status.signoff &&
|
||||
status.signoff.external_balance != null &&
|
||||
status.signoff.through_date === status.as_of.slice(0, 10)
|
||||
) {
|
||||
const external = status.signoff.external_balance
|
||||
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
|
||||
status.external_balance = external
|
||||
status.difference = difference
|
||||
status.unexplained_difference = difference
|
||||
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
|
||||
import { getAccountStatus } from './service'
|
||||
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
|
||||
@@ -31,6 +32,7 @@ export type SignoffErrorCode =
|
||||
| 'SIGNOFF_NOT_FOUND'
|
||||
| 'ALREADY_REOPENED'
|
||||
| 'SIGNOFF_RACE'
|
||||
| 'EXTERNAL_BALANCE_NOT_ALLOWED'
|
||||
|
||||
export class ReconciliationSignoffError extends Error {
|
||||
readonly code: SignoffErrorCode
|
||||
@@ -48,6 +50,13 @@ export interface SignoffInput {
|
||||
note?: string | null
|
||||
/** Sign even though the engine reports an unexplained difference or an unknown outside balance. Needs a note. */
|
||||
force?: boolean
|
||||
/**
|
||||
* The balance per the signer's underlag, in ledger sign (liabilities
|
||||
* negative). Only for manual accounts without a system specification; the
|
||||
* bank, the skattekonto and the reskontra/semesterskuld accounts have their
|
||||
* own outside truth and refuse it.
|
||||
*/
|
||||
external_balance?: number | null
|
||||
}
|
||||
|
||||
export interface SignoffOptions {
|
||||
@@ -90,8 +99,7 @@ export async function signOffAccount(
|
||||
options: SignoffOptions = {},
|
||||
): Promise<SignoffResult | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
// Manual accounts get their adapter later; until then they are not reconcilable.
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
if (!parsed) return null
|
||||
const today = options.today ?? isoToday()
|
||||
const throughDate = input.through_date
|
||||
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
|
||||
@@ -125,6 +133,24 @@ export async function signOffAccount(
|
||||
)
|
||||
}
|
||||
|
||||
// A stated outside balance is the manual adapter's outside truth for the
|
||||
// date. Where the system keeps a specification (or a feed) the stated
|
||||
// number would only hide a real difference, so it is refused there.
|
||||
if (input.external_balance != null) {
|
||||
if (status.kind !== 'manual' || status.manual?.specification) {
|
||||
throw new ReconciliationSignoffError(
|
||||
'Kontot har redan en sanning utanför bokföringen (bank, Skatteverket, reskontra eller beräkning). Ange inget saldo manuellt; signera med en notering om något avviker.',
|
||||
'EXTERNAL_BALANCE_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
const external = roundOre(input.external_balance)
|
||||
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
|
||||
status.external_balance = external
|
||||
status.difference = difference
|
||||
status.unexplained_difference = difference
|
||||
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
|
||||
}
|
||||
|
||||
const unexplained = status.unexplained_difference
|
||||
const reconciled = unexplained != null && Math.abs(unexplained) < 0.005
|
||||
if (!reconciled && !force) {
|
||||
@@ -212,7 +238,7 @@ export async function reopenSignoff(
|
||||
input: { reason?: string | null } = {},
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
if (!parsed) return null
|
||||
const existing = await getSignoffById(supabase, companyId, accountKey, signoffId)
|
||||
if (!existing) {
|
||||
throw new ReconciliationSignoffError('Signeringen hittades inte.', 'SIGNOFF_NOT_FOUND')
|
||||
|
||||
+15
-2
@@ -7806,7 +7806,7 @@
|
||||
},
|
||||
"reconciliation": {
|
||||
"title": "Reconciliation",
|
||||
"help_text": "Every account with a truth outside the ledger, the bank or the tax account, is reconciled here. What exists outside is compared with what is booked, each row on one side is linked to a row on the other, and whatever remains is explained or booked.",
|
||||
"help_text": "Every account with a truth outside the books: the bank, the tax account and the other balance sheet accounts against a ledger, a calculation or your supporting documents. Link the rows, explain the difference and sign off when the account agrees.",
|
||||
"rail_heading": "Accounts",
|
||||
"rail_synced": "fetched {date}",
|
||||
"rail_never_synced": "never fetched",
|
||||
@@ -7926,7 +7926,20 @@
|
||||
"row_mark_ib": "Mark as opening balance",
|
||||
"row_move": "Move to account",
|
||||
"toast_marked_ib": "The voucher was marked as opening balance",
|
||||
"toast_moved": "The transaction was moved to {account}"
|
||||
"toast_moved": "The transaction was moved to {account}",
|
||||
"rail_group_manual": "Other balance sheet accounts",
|
||||
"rail_never_signed": "not signed off",
|
||||
"state_unsigned": "Not signed off",
|
||||
"tile_external_manual": "Balance per supporting documents",
|
||||
"tile_external_manual_sub": "entered when you sign",
|
||||
"tile_external_signed": "per sign-off through {date}",
|
||||
"tile_spec_today": "open items today",
|
||||
"action_open_ledger": "Open the general ledger",
|
||||
"manual_hint": "Nothing outside the books feeds this account. Compare the balance with your supporting documents, enter it when you sign, and add a note if something differs.",
|
||||
"manual_spec_hint": "The specification comes from the system: {label}. The difference should be zero before you sign; otherwise sign with a note.",
|
||||
"signoff_external_balance": "Balance per supporting documents",
|
||||
"signoff_external_balance_help": "Same sign as in the books, liabilities with a minus. Booked: {amount}.",
|
||||
"signoff_external_balance_optional": "Leave empty to sign with a note only."
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
|
||||
|
||||
+15
-2
@@ -7806,7 +7806,7 @@
|
||||
},
|
||||
"reconciliation": {
|
||||
"title": "Avstämning",
|
||||
"help_text": "Varje konto med en sanning utanför bokföringen, banken eller skattekontot, stäms av här. Det som finns utanför jämförs med det som är bokfört, varje rad på ena sidan kopplas till en rad på den andra, och det som blir kvar förklaras eller bokförs.",
|
||||
"help_text": "Varje konto med en sanning utanför bokföringen: banken, skattekontot och övriga balanskonton mot reskontra, beräkning eller ditt underlag. Koppla raderna, förklara differensen och signera när kontot stämmer.",
|
||||
"rail_heading": "Konton",
|
||||
"rail_synced": "hämtat {date}",
|
||||
"rail_never_synced": "aldrig hämtat",
|
||||
@@ -7926,7 +7926,20 @@
|
||||
"row_mark_ib": "Märk som IB",
|
||||
"row_move": "Flytta till konto",
|
||||
"toast_marked_ib": "Verifikatet markerades som ingående balans",
|
||||
"toast_moved": "Transaktionen flyttades till {account}"
|
||||
"toast_moved": "Transaktionen flyttades till {account}",
|
||||
"rail_group_manual": "Övriga balanskonton",
|
||||
"rail_never_signed": "inte avstämt",
|
||||
"state_unsigned": "Inte avstämt",
|
||||
"tile_external_manual": "Saldo enligt underlag",
|
||||
"tile_external_manual_sub": "anges när du signerar",
|
||||
"tile_external_signed": "enligt signering t.o.m. {date}",
|
||||
"tile_spec_today": "öppna poster idag",
|
||||
"action_open_ledger": "Öppna huvudboken",
|
||||
"manual_hint": "Kontot hämtas inte från någon källa utanför bokföringen. Jämför saldot med ditt underlag, ange det när du signerar och skriv en notering om något avviker.",
|
||||
"manual_spec_hint": "Specifikationen kommer från systemet: {label}. Differensen ska vara noll innan du signerar, annars signerar du med en notering.",
|
||||
"signoff_external_balance": "Saldo enligt underlag",
|
||||
"signoff_external_balance_help": "Samma tecken som i bokföringen, skulder anges med minus. Bokfört: {amount}.",
|
||||
"signoff_external_balance_optional": "Lämna tomt om du bara vill signera med en notering."
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
|
||||
|
||||
@@ -162,6 +162,7 @@ Response `200`:
|
||||
counts: { proposed: number, unmatched_external: number, unmatched_ledger: number, matched: number, ignored: number },
|
||||
skattekonto: { saldo_skatteverket: number, fetched_at: string, history_start: string, opening_difference: number, upcoming_count: number, upcoming_total: number, ledger_balance_before_start: number },
|
||||
bank: Record<string, unknown>,
|
||||
manual?: { period_id: string, period_start: string, period_end: string, opening_balance: number, movement: number, closing_balance: number, specification: { provider: "ar" | "ap" | "vacation", label_sv: string, label_en: string, amount: number, unconverted_fx_count: number } },
|
||||
signoff?: { id: string, account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, note: string, signed_by: string, signed_at: string, reopened_at: string, reopened_by: string, reopen_reason: string }
|
||||
},
|
||||
meta: {
|
||||
@@ -443,13 +444,13 @@ Response `200`:
|
||||
**Mark an account reconciled through a date (sign-off).**
|
||||
`scope:reconciliation:signoff · risk:medium · dry-run · reversible`
|
||||
|
||||
Body: { through_date: "YYYY-MM-DD", note?, force? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.
|
||||
Body: { through_date: "YYYY-MM-DD", note?, force?, external_balance? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). For a manual:NNNN account without a system specification (anything but 1510/2440/2920/2940), external_balance is the balance per the signer's underlag in ledger sign (liabilities negative); the difference against the booked balance is recorded, and a non-zero one still needs force + note. On bank, skattekonto and specification accounts external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.
|
||||
|
||||
**Use when:** The month (or period) is explained and you want the account marked as reconciled through its last day, as a human would in the Avstämning page.
|
||||
**Do not use for:** Linking rows or booking anything: a sign-off changes no data in the ledger. Use .../links and the booking endpoints first.
|
||||
|
||||
**Pitfalls:**
|
||||
- Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.
|
||||
- Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED, EXTERNAL_BALANCE_NOT_ALLOWED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.
|
||||
- force: true without a note is NOTE_REQUIRED: the note is what the next reader sees next to the non-zero difference.
|
||||
- Idempotency-Key is required; repeating the same key replays the first response.
|
||||
|
||||
@@ -460,7 +461,7 @@ Body: { through_date: "YYYY-MM-DD", note?, force? }. Recomputes the bridge throu
|
||||
|
||||
Request body:
|
||||
```ts
|
||||
{ through_date: string, note?: string, force?: boolean }
|
||||
{ through_date: string, note?: string, force?: boolean, external_balance?: number }
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
Reference in New Issue
Block a user