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:
co-authored by
Claude Opus 4.8
parent
331ae11867
commit
c74b19df1b
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user