Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)

* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-03 10:52:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 331ae11867
commit c74b19df1b
183 changed files with 19621 additions and 4175 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ import { formatCurrency } from '@/lib/utils'
// Reject is always one-click.
//
// The card posts to the existing /api/pending-operations/<id>/{commit,reject}
// endpoints — same surface the gnubok "Förslag" page uses, so there is
// endpoints — same surface the Accounted "Förslag" page uses, so there is
// exactly one approval source of record.
//
// Structured preview: when the staged envelope carries a preview object, we
@@ -228,7 +228,7 @@ export default function ApprovalCard({
}
}
// The server's `message` field (e.g. "Operation staged for review …
// Open the gnubok web app to approve or reject it.") was written for
// Open the Accounted web app to approve or reject it.") was written for
// MCP clients without an inline approval surface. Inside the in-app
// chat it's redundant noise — the agent already narrated the why
// above the card. We keep it accessible via aria-description for
+2 -2
View File
@@ -81,13 +81,13 @@ export default function ChatSidebar({ initialConversations }: Props) {
// conversation pane runs nearly edge-to-edge.
const [collapsed, setCollapsed] = useState(true)
useEffect(() => {
const stored = localStorage.getItem('gnubok:chat-sidebar-collapsed')
const stored = localStorage.getItem('Accounted:chat-sidebar-collapsed')
if (stored === 'false') setCollapsed(false)
}, [])
const toggleCollapsed = () => {
setCollapsed(c => {
const next = !c
try { localStorage.setItem('gnubok:chat-sidebar-collapsed', next ? 'true' : 'false') } catch {}
try { localStorage.setItem('Accounted:chat-sidebar-collapsed', next ? 'true' : 'false') } catch {}
return next
})
}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { useCompany } from '@/contexts/CompanyContext'
import type { CashAccount } from '@/types'
const STORAGE_KEY_PREFIX = 'gnubok:cash-account:'
const STORAGE_KEY_PREFIX = 'Accounted:cash-account:'
interface Props {
/**
+1 -1
View File
@@ -15,7 +15,7 @@ import { Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import type { FiscalPeriod } from '@/types'
const STORAGE_KEY_PREFIX = 'gnubok:fiscal-year:'
const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
const ALL_YEARS_VALUE = '__all__'
interface Props {
+1 -1
View File
@@ -25,7 +25,7 @@ interface Props {
className?: string
}
const STORAGE_KEY_PREFIX = 'gnubok:report-range-preset:'
const STORAGE_KEY_PREFIX = 'Accounted:report-range-preset:'
const PRESETS: Preset[] = ['full_year', 'ytd', 'this_month', 'last_month', 'this_quarter', 'custom']
@@ -140,7 +140,7 @@ interface SIEFileStatus {
alreadyImported: boolean
importedAt: string | null
// New (period-based) detection. When present, this fiscal year already has a
// completed import in gnubok and a re-sync will replace it (cancelling the
// completed import in Accounted and a re-sync will replace it (cancelling the
// imported journal entries; user-created entries are untouched).
previousImport: {
importedAt: string | null
+7 -1
View File
@@ -152,7 +152,13 @@ export default function LinkVoucherPicker({
})
return
}
toast({ title: t('link_success_title'), variant: 'success' })
const body = await response.json().catch(() => null)
const reconciledTxId = body?.data?.reconciled_transaction_id ?? null
toast({
title: t('link_success_title'),
description: reconciledTxId ? t('link_success_tx_reconciled') : undefined,
variant: 'success',
})
onLinked()
} catch (err) {
toast({
@@ -24,7 +24,7 @@ export interface MemberCompany {
export interface TicPickerCompany {
role: EnrichmentCompanyRole
/** 'new' = not in gnubok, can set up. 'exists' = in gnubok but user is not a member. */
/** 'new' = not in Accounted, can set up. 'exists' = in Accounted but user is not a member. */
status: 'new' | 'exists'
}
+1 -1
View File
@@ -197,7 +197,7 @@ export default function NewUserChecklist({
</div>
{/* Step 3: Connect Skatteverket — only when the extension is enabled.
Optional: connecting here lets gnubok submit moms + AGI and read
Optional: connecting here lets Accounted submit moms + AGI and read
skattekonto saldo, but the user can skip and do it later from
/settings/skatteverket. The OAuth flow returns to the dashboard
via return_to=/, which clears the gate via the same path the
@@ -96,7 +96,7 @@ export default function Step2CompanyDetails({
const orgNumber = watch('org_number')
// Debounced duplicate check against gnubok's own companies table. Runs in
// Debounced duplicate check against Accounted's own companies table. Runs in
// parallel with the TIC lookup — they don't conflict. On match, the submit
// button is disabled; the server action would also reject ('org_number_exists')
// but blocking client-side avoids a wasted roundtrip.
@@ -153,6 +153,8 @@ export function BankReconciliationView() {
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
// Per-verifikat loading for the "Märk som ingående balans" re-tag action.
const [markLoading, setMarkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Opt-in: also surface vouchers already matched to a bank transaction as
@@ -408,6 +410,34 @@ export function BankReconciliationView() {
}
}
/**
* Re-tag a manual/import voucher that is really an ingående balans as
* source_type='opening_balance'. Such a voucher (common after a migration
* where the IB was booked as an ordinary verifikat) otherwise stays in the
* period movement and shows up as a phantom difference equal to the IB. After
* re-tagging it drops out of the diff and is surfaced as "IB — räknas inte".
*/
const handleMarkOpeningBalance = async (journalEntryId: string) => {
setMarkLoading(journalEntryId)
try {
const res = await fetch('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
await fetchAll()
}
} catch {
setError('Kunde inte markera verifikationen som ingående balans')
} finally {
setMarkLoading(null)
}
}
/**
* Inline one-click booking for an unmatched transaction with no upstream
* voucher to match against (ränteintäkter, bankavgifter, valutakurs-
@@ -883,6 +913,9 @@ export function BankReconciliationView() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-xs text-muted-foreground">
Är en manuellt eller importerat bokförd verifikation egentligen en ingående balans? Markera den som IB — då räknas den inte med i avstämningen utan visas separat som ingående balans.
</p>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
@@ -891,11 +924,13 @@ export function BankReconciliationView() {
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Typ</th>
<th className="py-2 w-36"></th>
</tr>
</thead>
<tbody>
{unmatchedGlLines.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
return (
<tr key={line.line_id} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">
@@ -909,6 +944,20 @@ export function BankReconciliationView() {
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
<td className="py-2 text-right">
{isRetaggable && (
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
disabled={markLoading === line.journal_entry_id}
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
title="Markera verifikationen som ingående balans — den utesluts då från avstämningen"
>
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
</Button>
)}
</td>
</tr>
)
})}
+1 -1
View File
@@ -594,7 +594,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
)}
{/* Local pre-flight check results — surfaced separately from SKV's
kontroller so the user knows these are gnubok's own sanity checks,
kontroller so the user knows these are Accounted's own sanity checks,
not Skatteverket's. ERRORs block the submit/validate buttons. */}
{localChecks.length > 0 && (
<div className="space-y-1.5">
+2 -2
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react'
/**
* Tracks the last few report slugs the user opened, per company, in
* localStorage. Mirrors the `gnubok:<key>:<companyId>` convention used by
* localStorage. Mirrors the `Accounted:<key>:<companyId>` convention used by
* FiscalYearSelector (STORAGE_KEY_PREFIX). Powers the "Senast öppnade" shelf
* so returning users skip the library hop.
*/
const STORAGE_KEY_PREFIX = 'gnubok:report-recents:'
const STORAGE_KEY_PREFIX = 'Accounted:report-recents:'
const MAX_RECENTS = 4
export function useRecentReports(companyId: string | null | undefined) {
+1 -1
View File
@@ -26,7 +26,7 @@ interface EstimateResponse {
within_limit: boolean
}
const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download'
const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download'
export function BackupDownloadForm() {
const t = useTranslations('settings_backup_download')
@@ -97,7 +97,7 @@ export default function TransactionInboxCard({
// upload POST succeeds, without waiting for the parent to refetch. The
// next parent refresh will sync; in the meantime the user sees the
// correct visual state immediately. Same hook handles agent-chat uploads
// via the gnubok:transaction-document-linked window event (AgentChat
// via the Accounted:transaction-document-linked window event (AgentChat
// dispatches it after /api/agent/upload returns).
const [optimisticDocumentId, setOptimisticDocumentId] = useState<string | null>(null)
useEffect(() => {
@@ -106,8 +106,8 @@ export default function TransactionInboxCard({
if (!detail || detail.transaction_id !== transaction.id || !detail.document_id) return
setOptimisticDocumentId(detail.document_id)
}
window.addEventListener('gnubok:transaction-document-linked', onLinked)
return () => window.removeEventListener('gnubok:transaction-document-linked', onLinked)
window.addEventListener('Accounted:transaction-document-linked', onLinked)
return () => window.removeEventListener('Accounted:transaction-document-linked', onLinked)
}, [transaction.id])
const attachedDocumentId =
optimisticDocumentId ?? (transaction as { document_id?: string | null }).document_id ?? null
+1 -1
View File
@@ -17,7 +17,7 @@ interface RetentionNoticeProps {
* account danger zone and the company danger zone.
*
* Swedish Bokföringslagen (BFL) 7 kap. 2§ requires räkenskapsinformation
* to be retained for 7 years. gnubok is the system of record, so deleting
* to be retained for 7 years. Accounted is the system of record, so deleting
* a company or an account does not remove the underlying data — it only
* hides it from the UI and anonymizes PII where applicable.
*/