Bug/invalid imports (#1146)

* feat: add Accounted MCP namespace

* fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice

The missing-underlag surfaces only accepted a document directly linked to
the entry, so payment verifikat for supplier invoices (doc on the
registration entry per design) and entries whose doc was pinned to the
bank transaction before matching were falsely flagged; opening the entry
showed the referenced doc and cleared the warning client-side, and it
came back on reload.

- verifikat_without_documents + transactions_without_documents now treat
  an entry as covered when a supplier invoice referencing it (registration
  or payment FK, or a supplier_invoice_payments row) carries a document
  anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till
  underlag; anchoring required because the WORM deletion guards key on
  document_attachments.journal_entry_id)
- match-supplier-invoice routes (dashboard + v1) propagate the
  transaction's pinned document onto the payment verifikat, mirroring the
  categorize route; migration backfills rows already written (open
  unlocked periods, company-guarded, never steals a linked doc)
- /api/documents/counts, the transactions-page badges, the bulk "Inget
  underlag krävs" count and the push-notification scheduler share the
  same reference-aware predicate, so every surface agrees with the RPC
- counts route validates journal_entry_ids as UUIDs (they are
  interpolated into a PostgREST or-filter)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): align table columns flush with page edges

Collapse the checkbox gutter column to zero width and hang the
hover-revealed checkbox/expand chevron in the page margins, drop the
outer padding so DATUM sits flush left and STATUS flush right, and
tuck the overflow-menu dots under the middle of the STATUS header.
Applied to both the inbox and history tables so they stay identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation

The ARL 5:8 roll-forward note recomputed depreciation from its own
day-based linear formula (365.25/12 month length, non-inclusive day
count, linear only), drifting ~20 kr per year per asset from the
ledger-driven resultat- and balansrakning and misstating non-linear
methods entirely. Note figures now come from posted
depreciation_schedules rows (the same source disposeAsset reverses),
falling back to the engine's computeAnnualDepreciation when nothing is
posted; pre-onboarding opening balances iterate prior years through
the engine. Adds a note-vs-trial-balance tie-out warning (accounts
1000-1299, over 1 kr) surfaced before download.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(stripe): move connect and sync surface from settings to import page

Stripe's transaction feed is a continuous import source in the same
category as the PSD2 bank connection, so its connect/sync surface now
lives on the import page as a source card (mode=stripe), gated
"kommer snart" on hosted like before; self-hosted keeps the full panel.

- Import page: Stripe card after Koppla bank, renders the existing
  StripeSettingsPanel via the settings-panel registry
- OAuth callback and panel cleanup return to /import?mode=stripe
- Settings > Betalningar retired: nav item removed, route redirects,
  PaymentsSettingsContent deleted, legacy ?tab=payments mapped
- New import.stripe_* strings in sv+en; dead settings_nav.payments removed

Crons and sync logic unchanged; payment-link settings stay in the
invoicing section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(underlag): paginate missing-underlag cron and harden doc-surface queries

Resolve PR review findings on bug/invalid-imports:
- notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows
  the capped reads produced false "saknade underlag" notifications
- bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list
  stays under the PostgREST URL limit
- bulk-missing + transactions page: UUID-guard the .or()-interpolated id
  lists, matching documents/counts
- match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on
  the non-fatal doc-link warning
- well-known/oauth-protected-resource: document the tool_namespace allow-list
- messages/en: reword stripe_description
- DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls

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

* fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-24 15:03:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 51ca574ca4
commit 53e343ee92
71 changed files with 3085 additions and 452 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
---
paths:
- "extensions/general/mcp-server/**"
- "packages/accounted-mcp/**"
- "packages/gnubok-mcp/**"
---
@@ -12,7 +13,9 @@ Accounted exposes its bookkeeping engine as an MCP server for Claude Desktop/Cod
**OAuth 2.1** for Claude and ChatGPT connectors: `.well-known/oauth-protected-resource` + `.well-known/oauth-authorization-server` discovery; `/api/mcp-oauth/authorize`, `/token` (PKCE), `/register`. Stateless AES-256-GCM auth codes (`lib/auth/oauth-codes.ts`). Single-use via `oauth_used_codes`. Allowlist: `claude.ai/api/*`, `claude.com/api/*`, `chatgpt.com/connector/oauth/*`, `chatgpt.com/connector_platform_oauth_redirect`, `localhost`.
**npm package** (`packages/gnubok-mcp`): Stdio-to-HTTP bridge; users run `npx gnubok-mcp` with API key.
**npm packages**: `packages/accounted-mcp` is the Accounted stdio-to-HTTP bridge for new installs. `packages/gnubok-mcp` is the permanent compatibility package for existing configurations.
**Tool namespaces**: internal tool ids and authorization maps remain canonical `gnubok_*`. The Accounted MCP surface is explicitly selected with `?tool_namespace=accounted`; it advertises `accounted_*` and accepts both aliases. Requests without the selector must retain the legacy server identity, catalog, and behavior.
## Tool authoring conventions (enforced by tests)
+1 -1
View File
@@ -27,7 +27,7 @@ General prohibitions:
- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge.
- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, and the `gnubok-mcp` compatibility package. Renaming or removing them breaks live sessions, API keys, invites, and existing MCP connections. New MCP installs use the additive `accounted-mcp` package and `accounted_*` aliases.
- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
- **Never open, start, or run Docker locally.** Do not run Docker commands or commands that start Docker-managed services.
- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
+1 -1
View File
@@ -24,7 +24,7 @@ General prohibitions:
- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge.
- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, and the `gnubok-mcp` compatibility package. Renaming or removing them breaks live sessions, API keys, invites, and existing MCP connections. New MCP installs use the additive `accounted-mcp` package and `accounted_*` aliases.
- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
- **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon.
+6
View File
@@ -354,3 +354,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry.
[2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists).
[2026-07-24] Journey reducer lives in lib/onboarding-journey/, not components/onboarding/journey/ as the plan sketched: pure logic belongs in the vitest scope (lib/ + app/api/), and the plan's reducer test matrix requires it. Components stay copy-agnostic; i18n keys land with their consumer in PR C.
[2026-07-24] Missing-underlag predicate made reference-aware (BFL 5 kap 7 § hänvisning): an entry referenced by a supplier invoice whose document is retained AND anchored (document_attachments.journal_entry_id set) counts as covered; anchoring required because every WORM deletion guard keys on journal_entry_id, so an unanchored doc is deletable and must keep the warning alive (adversarial-review finding). match_batch_allocate RPC left without tx-doc propagation on purpose: its payment JEs are silenced via the supplier_invoice_payments reference arm, and touching that large RPC for a data-cosmetic link was judged out of scope for the /fix.
[2026-07-24] Stripe UI re-homed to /import (mode=stripe); kept /settings/payments as a redirect instead of deleting the route: old links and in-flight OAuth bounce-backs must not 404. Kept the settings_payments i18n namespace for the panel to avoid rename churn.
[2026-07-24] Anlaggningstillgangar note (ARL 5:8) anchored to posted depreciation_schedules with computeAnnualDepreciation as fallback, over (a) fixing the old continuous day-based formula or (b) deriving the note from ledger 10xx-12xx account movements: posted schedules ARE the booked amounts (and exactly what disposeAsset reverses), the engine fallback previews what would be booked with full method dispatch, while (a) cannot represent declining-balance/K3 components and caused the 20 kr RR/BR-vs-note drift, and (b) needs fragile account-to-category mapping. Pre-onboarding ibAck iterates synthetic 12-month windows through the engine (approximation surfaced by a new note-vs-TB tie-out warning). K3 adaptAsset months-based per-component approximation left untouched (scoped follow-up).
[2026-07-24] Accounted MCP naming is an additive namespace selected with tool_namespace=accounted: internal gnubok_* ids, authorization maps, API-key prefixes, and the gnubok-mcp package remain canonical compatibility surfaces, while new clients advertise accounted_* aliases through accounted-mcp so existing connections never invalidate.
[2026-07-24] PR-review pass on bug/invalid-imports: declined CodeRabbit's ask to fill un-posted prior years in asset-note-figures ibAck via the engine when priorPosted.length>0. The note must tie out to the ledger-driven balansrakning, which reflects posted-only accumulated depreciation; estimating a skipped year would over-state ibAck and BREAK the tie-out this module exists to preserve. A posted-history gap is a real books gap that the build-data tie-out warning correctly surfaces (fix = post the missing year, not paper over it). Engine fallback stays gated to priorPosted.length===0 (pre-onboarding, nothing booked).
[2026-07-24] Kept the leading-`!` Tailwind important syntax (`!p-0`/`!pl-0`/`!pr-0`) in the transaction tables over CodeRabbit's trailing-`!` (`p-0!`) rewrite: verified against the installed tailwindcss 4.1.18 compiler that `!p-0` alone emits `.\!p-0{padding:… !important}`, so both forms work in v4 and the flush-edge columns are not broken. Declined the 18-site churn.
+43 -3
View File
@@ -20,7 +20,7 @@ import {
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
@@ -1943,11 +1943,16 @@ function CSVDataImportWizard() {
// reconnect entry point), which the old inline wizard here did not.
const BankingPanel = getSettingsPanel('enable-banking')
// Same registry mechanism for the Stripe connect/sync surface: the feed of
// payments, fees and payouts is an import source in the same category as the
// PSD2 bank connection above.
const StripePanel = getSettingsPanel('stripe')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration'
type ImportMode = null | 'psd2' | 'stripe' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
@@ -1990,7 +1995,7 @@ export default function ImportPage() {
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'sie', 'csv_data']
: ['psd2', 'bank', 'sie', 'csv_data', 'migration']
: ['psd2', 'stripe', 'bank', 'sie', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
@@ -2033,6 +2038,11 @@ export default function ImportPage() {
// Extensions are active if compiled in: no runtime toggle check needed
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
// Hosted: Stripe Connect has not launched, so the card is "coming soon".
// The panel carries the same gate internally for ?mode=stripe deep links.
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const stripeDisabled = isSandbox || !isSelfHosted
return (
<div className="space-y-8">
@@ -2095,6 +2105,21 @@ export default function ImportPage() {
onClick={() => setMode('psd2')}
/>
)}
{hasStripeExtension && (
<ImportRow
title={t('stripe_title')}
sub={t('stripe_description')}
chip={
!isSelfHosted ? (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
{t('stripe_coming_soon')}
</span>
) : undefined
}
disabled={stripeDisabled}
onClick={() => setMode('stripe')}
/>
)}
{hasMigrationExtension && (
<ImportRow
title={t('migration_title')}
@@ -2231,6 +2256,21 @@ export default function ImportPage() {
</Card>
)
)}
{mode === 'stripe' && (
hasStripeExtension && StripePanel ? (
<StripePanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">{t('stripe_not_enabled_title')}</p>
<p className="max-w-md text-sm text-muted-foreground">
{t('stripe_not_enabled_description')}
</p>
</CardContent>
</Card>
)
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
+1 -1
View File
@@ -10,7 +10,7 @@ import { ActiveCompanyBadge } from '@/components/settings/ActiveCompanyBadge'
const TAB_TO_ROUTE: Record<string, string> = {
company: '/settings/company',
invoicing: '/settings/invoicing',
payments: '/settings/payments',
payments: '/import?mode=stripe',
bookkeeping: '/settings/bookkeeping',
tax: '/settings/tax',
team: '/settings/team',
+4 -2
View File
@@ -1,5 +1,7 @@
import { PaymentsSettingsContent } from '@/components/settings/sections/PaymentsSettingsContent'
import { redirect } from 'next/navigation'
// The Stripe connect/sync surface moved to the import page; this route stays
// as a redirect so old links, bookmarks and OAuth flows in flight keep working.
export default function PaymentsSettingsPage() {
return <PaymentsSettingsContent />
redirect('/import?mode=stripe')
}
+63 -10
View File
@@ -104,6 +104,12 @@ type SourceFilter = 'all' | 'bank' | 'bank:other' | 'skatteverket' | `acct:${str
const SOURCE_FILTER_STORAGE_KEY = 'Accounted:transaction-source-filter:v1'
// Journal-entry ids get interpolated into the supplier-invoice .or() filter
// string in the underlag-badge effect below. They come from journal_entries.id
// (DB-sourced), but this guard keeps the interpolated list UUID-only, matching
// /api/documents/counts.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
// Validates a persisted value. Stale acct:<id> entries (account removed or
// disabled) are caught later by the sourceItems stale-filter guard.
function isSourceFilter(value: string | null): value is SourceFilter {
@@ -671,12 +677,15 @@ export default function TransactionsPage() {
setIsLoadingMore(false)
}
// Underlag-status enrichment for booked rows. Three RLS-scoped reads per
// Underlag-status enrichment for booked rows. Five RLS-scoped reads per
// 150-id chunk (PostgREST .in() URL-length convention, see
// lib/worklist/categories.ts): the JEs' source types, which JEs have a
// current-version document, and which are exempted via
// journal_entry_no_doc_required. Incremental: only fetches JE ids not yet
// requested, so loadMoreTransactions pages are covered without refetching.
// current-version document, which are covered by a supplier invoice's
// retained document (BFL 5 kap 7 § hänvisning: registration/payment FK or a
// supplier_invoice_payments row: mirrors the verifikat_without_documents
// RPC), and which are exempted via journal_entry_no_doc_required.
// Incremental: only fetches JE ids not yet requested, so
// loadMoreTransactions pages are covered without refetching.
// Soft-fails to "no badges" on error.
useEffect(() => {
if (!companyId) return
@@ -700,7 +709,10 @@ export default function TransactionsPage() {
const merged: Record<string, JeUnderlagStatus> = {}
for (let i = 0; i < newIds.length; i += IN_CLAUSE_CHUNK) {
const chunk = newIds.slice(i, i + IN_CLAUSE_CHUNK)
const [entriesRes, docsRes, exemptRes] = await Promise.all([
// Only UUIDs reach the interpolated .or() string (the .in() array
// filters are already injection-safe).
const chunkInList = `(${chunk.filter((id) => UUID_RE.test(id)).join(',')})`
const [entriesRes, docsRes, siRefRes, sipRefRes, exemptRes] = await Promise.all([
supabase
.from('journal_entries')
.select('id, source_type')
@@ -717,6 +729,23 @@ export default function TransactionsPage() {
.in('journal_entry_id', chunk)
.eq('company_id', companyId)
.eq('is_current_version', true),
supabase
.from('supplier_invoices')
.select(
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)',
)
.eq('company_id', companyId)
.not('document_id', 'is', null)
.or(
`registration_journal_entry_id.in.${chunkInList},payment_journal_entry_id.in.${chunkInList}`,
),
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))',
)
.eq('company_id', companyId)
.in('journal_entry_id', chunk),
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
@@ -724,10 +753,30 @@ export default function TransactionsPage() {
.eq('company_id', companyId),
])
// Soft-fail: keep the chunks that already succeeded.
if (entriesRes.error || docsRes.error || exemptRes.error) break
if (entriesRes.error || docsRes.error || siRefRes.error || sipRefRes.error || exemptRes.error) break
const jeIdsWithDocs = new Set(
(docsRes.data ?? []).map((d) => d.journal_entry_id as string),
)
for (const si of (siRefRes.data ?? []) as unknown as {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[]) {
if (!si.document?.journal_entry_id) continue // unanchored: not underlag
if (si.registration_journal_entry_id) jeIdsWithDocs.add(si.registration_journal_entry_id)
if (si.payment_journal_entry_id) jeIdsWithDocs.add(si.payment_journal_entry_id)
}
for (const sip of (sipRefRes.data ?? []) as unknown as {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[]) {
if (sip.journal_entry_id && sip.supplier_invoice?.document?.journal_entry_id) {
jeIdsWithDocs.add(sip.journal_entry_id)
}
}
const exemptIds = new Set(
(exemptRes.data ?? []).map((e) => e.journal_entry_id as string),
)
@@ -2401,15 +2450,19 @@ export default function TransactionsPage() {
</div>
)}
<div className="overflow-x-auto">
{/* Negative margin + matching padding: lets the hover-revealed
checkbox/chevron hang into the page margins without being
clipped by the overflow container, while the columns stay
flush with the page edges. */}
<div className="-mx-5 overflow-x-auto px-5 md:-mx-8 md:px-8">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-0 !p-0')} aria-hidden="true"></th>
<th className={cn(TH_CLASS, '!pl-0')}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'text-right !pr-0')}>{t('th_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { GET } from '../route'
describe('MCP protected-resource discovery', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('keeps the legacy MCP resource unchanged by default', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
const response = await GET(
new Request('https://app.gnubok.se/.well-known/oauth-protected-resource', {
headers: { host: 'app.gnubok.se' },
})
)
const body = await response.json()
expect(body.resource).toBe(
'https://app.gnubok.se/api/extensions/ext/mcp-server/mcp'
)
expect(body.authorization_servers).toEqual(['https://app.gnubok.se'])
})
it('advertises the exact Accounted namespace resource when requested', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
const response = await GET(
new Request(
'https://app.accounted.se/.well-known/oauth-protected-resource?tool_namespace=accounted',
{ headers: { host: 'app.accounted.se' } }
)
)
const body = await response.json()
expect(body.resource).toBe(
'https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted'
)
expect(body.authorization_servers).toEqual(['https://app.accounted.se'])
})
})
@@ -12,9 +12,18 @@ import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
*/
export async function GET(request: Request) {
const appUrl = resolveDiscoveryBaseUrl(request)
const resource = new URL('/api/extensions/ext/mcp-server/mcp', appUrl)
// `accounted` is the COMPLETE allow-list of reflectable namespaces. We never
// echo the inbound parameter value: on an exact match we set the fixed
// literal, so a crafted tool_namespace (URL-special chars, other values) can
// never reach the advertised resource URL. Do not loosen this to a broader
// match without re-checking every downstream consumer that parses `resource`.
if (new URL(request.url).searchParams.get('tool_namespace') === 'accounted') {
resource.searchParams.set('tool_namespace', 'accounted')
}
return NextResponse.json({
resource: `${appUrl}/api/extensions/ext/mcp-server/mcp`,
resource: resource.toString(),
authorization_servers: [appUrl],
scopes_supported: ['mcp'],
})
@@ -67,6 +67,8 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
it('dry_run counts only entries that are missing AND not exempt', async () => {
enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a has a document
enqueue({ data: [], error: null }) // no SI references with docs
enqueue({ data: [], error: null }) // no SI payment-row references
enqueue({ data: [{ journal_entry_id: 'b' }], error: null }) // b already exempt
const res = await POST(makeReq({ dry_run: true }))
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
@@ -74,9 +76,49 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
expect(body.data.count).toBe(1) // only c
})
it('dry_run excludes entries covered by a supplier-invoice reference with an anchored doc', async () => {
// a: covered via supplier_invoices.registration_journal_entry_id
// b: covered via a supplier_invoice_payments row whose SI has an anchored doc
// c: genuinely missing
// d: SI reference exists but its doc is UNANCHORED (deletable) → still missing
enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], error: null }) // candidates
enqueue({ data: [], error: null }) // no direct documents
enqueue({
data: [
{
registration_journal_entry_id: 'a',
payment_journal_entry_id: null,
document: { journal_entry_id: 'a' },
},
{
registration_journal_entry_id: 'd',
payment_journal_entry_id: null,
document: { journal_entry_id: null },
},
],
error: null,
})
enqueue({
data: [
{
journal_entry_id: 'b',
supplier_invoice: { document_id: 'doc-1', document: { journal_entry_id: 'x' } },
},
],
error: null,
})
enqueue({ data: [], error: null }) // no exemptions
const res = await POST(makeReq({ dry_run: true }))
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
expect(status).toBe(200)
expect(body.data.count).toBe(2) // c and d
})
it('marks the missing entries and returns the count', async () => {
enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
enqueue({ data: [], error: null }) // no documents
enqueue({ data: [], error: null }) // no SI references with docs
enqueue({ data: [], error: null }) // no SI payment-row references
enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a already exempt
enqueue({ error: null }) // helper upsert
const res = await POST(makeReq({ period_id: null, reason: 'Importerad' }))
@@ -25,6 +25,12 @@ const isoDate = z.string().refine(
{ message: 'Ogiltigt datum (förväntat YYYY-MM-DD)' },
)
// Journal-entry ids are interpolated into the supplier-invoice .or() filter
// string below, so they must be UUIDs. They originate from journal_entries.id
// (DB-sourced, never request input), but this guard keeps the injection-safety
// contract identical to /api/documents/counts.
const uuidSchema = z.string().uuid()
const BulkMissingSchema = z.object({
period_id: z.string().uuid().nullable().optional(),
// Single uppercase verifikationsserie (A-Z); the list sends null for "all".
@@ -90,16 +96,45 @@ export const POST = withRouteContext(
const candidateIds = candidates.map((e) => e.id)
const withDoc = new Set<string>()
const exempt = new Set<string>()
const LOOKUP_CHUNK = 300
// 150 keeps the embedded id lists well under PostgREST's URL-length limit:
// the supplier-invoice .or() below repeats the chunk twice (registration +
// payment FK), so a larger chunk would risk truncating the GET filter.
const LOOKUP_CHUNK = 150
for (let i = 0; i < candidateIds.length; i += LOOKUP_CHUNK) {
const chunk = candidateIds.slice(i, i + LOOKUP_CHUNK)
const [docRes, exemptRes] = await Promise.all([
// Only UUIDs reach the interpolated .or() string (the .in() array filters
// are already injection-safe); mirrors the guard in documents/counts.
const chunkInList = `(${chunk.filter((id) => uuidSchema.safeParse(id).success).join(',')})`
const [docRes, siRefRes, sipRefRes, exemptRes] = await Promise.all([
supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', chunk),
// BFL 5 kap 7 § hänvisning: an entry referenced by a supplier invoice
// whose source document is retained AND anchored to a journal entry
// is NOT missing underlag (only anchored docs sit behind the WORM
// deletion guards). Mirrors the verifikat_without_documents RPC;
// without this the bulk action would waive entries the warning no
// longer counts.
supabase
.from('supplier_invoices')
.select(
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)',
)
.eq('company_id', companyId)
.not('document_id', 'is', null)
.or(
`registration_journal_entry_id.in.${chunkInList},payment_journal_entry_id.in.${chunkInList}`,
),
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))',
)
.eq('company_id', companyId)
.in('journal_entry_id', chunk),
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
@@ -109,12 +144,38 @@ export const POST = withRouteContext(
if (docRes.error) {
return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 })
}
if (siRefRes.error) {
return NextResponse.json({ error: getUserErrorMessage(siRefRes.error) }, { status: 400 })
}
if (sipRefRes.error) {
return NextResponse.json({ error: getUserErrorMessage(sipRefRes.error) }, { status: 400 })
}
if (exemptRes.error) {
return NextResponse.json({ error: getUserErrorMessage(exemptRes.error) }, { status: 400 })
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
}
for (const r of (siRefRes.data ?? []) as unknown as {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[]) {
if (!r.document?.journal_entry_id) continue // unanchored: not underlag
if (r.registration_journal_entry_id) withDoc.add(r.registration_journal_entry_id)
if (r.payment_journal_entry_id) withDoc.add(r.payment_journal_entry_id)
}
for (const r of (sipRefRes.data ?? []) as unknown as {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[]) {
if (r.journal_entry_id && r.supplier_invoice?.document?.journal_entry_id) {
withDoc.add(r.journal_entry_id)
}
}
for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
exempt.add(r.journal_entry_id)
}
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
import { NextResponse } from 'next/server'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() }))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { getActiveCompanyId } from '@/lib/company/context'
const mockUser = { id: 'user-1', email: 't@t.se' }
const JE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const JE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const JE_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
function makeReq(ids: string[]) {
return new Request(
`http://localhost/api/documents/counts?journal_entry_ids=${ids.join(',')}`,
)
}
beforeEach(() => {
vi.clearAllMocks()
reset()
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
user: mockUser,
supabase: mockSupabase,
})
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
})
// Queue order mirrors the route's Promise.all: direct docs, supplier_invoices
// references, supplier_invoice_payments references. The `document` embed
// carries the anchor state (journal_entry_id) of the SI's retained doc.
function enqueueAll(opts: {
direct?: Array<{ id: string; journal_entry_id: string }>
si?: Array<{
document_id: string
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}>
sip?: Array<{
journal_entry_id: string
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}>
}) {
enqueue({ data: opts.direct ?? [], error: null })
enqueue({ data: opts.si ?? [], error: null })
enqueue({ data: opts.sip ?? [], error: null })
}
describe('GET /api/documents/counts', () => {
it('returns 401 when not authenticated', async () => {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq([JE_A]))
expect((await parseJsonResponse(res)).status).toBe(401)
})
it('returns 400 without journal_entry_ids', async () => {
const res = await GET(new Request('http://localhost/api/documents/counts'))
expect((await parseJsonResponse(res)).status).toBe(400)
})
it('returns 400 for more than 50 ids', async () => {
const ids = Array.from({ length: 51 }, (_, i) => `id-${i}`)
const res = await GET(makeReq(ids))
expect((await parseJsonResponse(res)).status).toBe(400)
})
it('returns 400 for non-UUID ids (they are interpolated into a PostgREST or-filter)', async () => {
const res = await GET(makeReq([JE_A, 'registration_journal_entry_id.in.(x)']))
expect((await parseJsonResponse(res)).status).toBe(400)
})
it('counts direct attachments per entry', async () => {
enqueueAll({
direct: [
{ id: 'doc-1', journal_entry_id: JE_A },
{ id: 'doc-2', journal_entry_id: JE_A },
{ id: 'doc-3', journal_entry_id: JE_B },
],
})
const res = await GET(makeReq([JE_A, JE_B, JE_C]))
const { status, body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ [JE_A]: 2, [JE_B]: 1 })
})
it('counts a supplier invoice doc for both referenced entries (registration + payment)', async () => {
enqueueAll({
si: [
{
document_id: 'doc-si',
registration_journal_entry_id: JE_A,
payment_journal_entry_id: JE_B,
document: { journal_entry_id: JE_A },
},
],
})
const res = await GET(makeReq([JE_A, JE_B]))
const { body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(body.data).toEqual({ [JE_A]: 1, [JE_B]: 1 })
})
it('ignores an UNANCHORED supplier invoice doc (outside the WORM deletion guards)', async () => {
enqueueAll({
si: [
{
document_id: 'doc-si',
registration_journal_entry_id: JE_A,
payment_journal_entry_id: JE_B,
document: { journal_entry_id: null },
},
],
sip: [
{
journal_entry_id: JE_C,
supplier_invoice: { document_id: 'doc-si', document: { journal_entry_id: null } },
},
],
})
const res = await GET(makeReq([JE_A, JE_B, JE_C]))
const { body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(body.data).toEqual({})
})
it('counts a partial-payment reference via supplier_invoice_payments', async () => {
enqueueAll({
sip: [
{
journal_entry_id: JE_A,
supplier_invoice: { document_id: 'doc-si', document: { journal_entry_id: JE_B } },
},
],
})
const res = await GET(makeReq([JE_A]))
const { body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(body.data).toEqual({ [JE_A]: 1 })
})
it('deduplicates a doc that is both directly linked and referenced', async () => {
enqueueAll({
direct: [{ id: 'doc-si', journal_entry_id: JE_A }],
si: [
{
document_id: 'doc-si',
registration_journal_entry_id: JE_A,
payment_journal_entry_id: null,
document: { journal_entry_id: JE_A },
},
],
})
const res = await GET(makeReq([JE_A]))
const { body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(body.data).toEqual({ [JE_A]: 1 })
})
it('never returns entries the caller did not ask about', async () => {
enqueueAll({
si: [
{
document_id: 'doc-si',
// The SI's other FK points at an entry outside the request.
registration_journal_entry_id: JE_C,
payment_journal_entry_id: JE_A,
document: { journal_entry_id: JE_C },
},
],
})
const res = await GET(makeReq([JE_A]))
const { body } = await parseJsonResponse<{ data: Record<string, number> }>(res)
expect(body.data).toEqual({ [JE_A]: 1 })
expect(body.data[JE_C]).toBeUndefined()
})
it('returns 500 when a lookup fails', async () => {
enqueue({ data: null, error: { message: 'boom' } })
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const res = await GET(makeReq([JE_A]))
expect((await parseJsonResponse(res)).status).toBe(500)
})
})
+103 -16
View File
@@ -1,11 +1,28 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const uuidSchema = z.string().uuid()
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
* Returns attachment counts per journal entry ID.
* Max 50 IDs per request.
* Returns underlag counts per journal entry ID.
* Max 50 IDs per request; every ID must be a UUID (the ids are interpolated
* into a PostgREST .or() filter string, so validation doubles as injection
* protection).
*
* Counts BOTH direct attachments (document_attachments.journal_entry_id) and
* documents retained on a supplier invoice that references the entry
* (registration/payment FK or a supplier_invoice_payments row). BFL 5 kap 7 §
* accepts underlag via hänvisning, and the expanded-row view
* (JournalEntryAttachments) already lists referenced docs: counting only
* direct links here made the list warning disagree with the opened row.
* A referenced doc counts only when ANCHORED to a journal entry
* (journal_entry_id set): unanchored docs sit outside the WORM deletion
* guards, so they must not silence the missing-underlag warning (mirrors the
* verifikat_without_documents RPC). Documents are deduplicated per entry so a
* doc that is both directly linked and referenced counts once.
*/
export const GET = withRouteContext('document.counts', async (request, ctx) => {
const { supabase, companyId } = ctx
@@ -27,23 +44,93 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => {
return NextResponse.json({ error: 'Maximum 50 IDs per request' }, { status: 400 })
}
const { data, error } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', ids)
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (ids.some((id) => !uuidSchema.safeParse(id).success)) {
return NextResponse.json(
{ error: 'journal_entry_ids must be UUIDs' },
{ status: 400 },
)
}
// Group and count by journal_entry_id
const counts: Record<string, number> = {}
for (const row of data || []) {
if (row.journal_entry_id) {
counts[row.journal_entry_id] = (counts[row.journal_entry_id] || 0) + 1
const inList = `(${ids.join(',')})`
const [directRes, siRes, sipRes] = await Promise.all([
supabase
.from('document_attachments')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', ids),
supabase
.from('supplier_invoices')
.select(
'document_id, registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)',
)
.eq('company_id', companyId)
.not('document_id', 'is', null)
.or(
`registration_journal_entry_id.in.${inList},payment_journal_entry_id.in.${inList}`,
),
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))',
)
.eq('company_id', companyId)
.in('journal_entry_id', ids),
])
if (directRes.error) {
return NextResponse.json({ error: getUserErrorMessage(directRes.error) }, { status: 500 })
}
if (siRes.error) {
return NextResponse.json({ error: getUserErrorMessage(siRes.error) }, { status: 500 })
}
if (sipRes.error) {
return NextResponse.json({ error: getUserErrorMessage(sipRes.error) }, { status: 500 })
}
// Distinct doc ids per entry: a supplier invoice's document referenced from
// both FK paths, or already directly linked, must not double count.
const docsByEntry = new Map<string, Set<string>>()
const add = (journalEntryId: string | null | undefined, documentId: string | null | undefined) => {
if (!journalEntryId || !documentId) return
let set = docsByEntry.get(journalEntryId)
if (!set) {
set = new Set<string>()
docsByEntry.set(journalEntryId, set)
}
set.add(documentId)
}
for (const row of (directRes.data ?? []) as { id: string; journal_entry_id: string | null }[]) {
add(row.journal_entry_id, row.id)
}
for (const row of (siRes.data ?? []) as unknown as {
document_id: string | null
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[]) {
if (!row.document?.journal_entry_id) continue // unanchored: not underlag
add(row.registration_journal_entry_id, row.document_id)
add(row.payment_journal_entry_id, row.document_id)
}
for (const row of (sipRes.data ?? []) as unknown as {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[]) {
if (!row.supplier_invoice?.document?.journal_entry_id) continue // unanchored
add(row.journal_entry_id, row.supplier_invoice.document_id)
}
// Referenced entries outside the requested set (an SI FK can point at an
// entry the caller didn't ask about) must not leak into the response.
const requested = new Set(ids)
const counts: Record<string, number> = {}
for (const [journalEntryId, docIds] of docsByEntry) {
if (requested.has(journalEntryId)) counts[journalEntryId] = docIds.size
}
return NextResponse.json({ data: counts })
+10 -8
View File
@@ -29,7 +29,9 @@ export async function GET(request: Request) {
const errorDescription = searchParams.get('error_description')
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const settingsUrl = `${baseUrl}/settings/payments`
// The Stripe surface lives on the import page (?mode=stripe opens the panel
// directly); appended params below must use '&' since the base has a query.
const returnUrl = `${baseUrl}/import?mode=stripe`
if (error) {
const errorMessage = errorDescription || error
@@ -55,12 +57,12 @@ export async function GET(request: Request) {
}
return NextResponse.redirect(
`${settingsUrl}?stripe_error=${encodeURIComponent(errorMessage)}`,
`${returnUrl}&stripe_error=${encodeURIComponent(errorMessage)}`,
)
}
if (!code || !state) {
return NextResponse.redirect(`${settingsUrl}?stripe_error=missing_parameters`)
return NextResponse.redirect(`${returnUrl}&stripe_error=missing_parameters`)
}
const supabase = await createServiceClient()
@@ -84,7 +86,7 @@ export async function GET(request: Request) {
hasCode: !!code,
})
return NextResponse.redirect(
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
`${returnUrl}&stripe_error=${encodeURIComponent('invalid_state')}`,
)
}
@@ -99,7 +101,7 @@ export async function GET(request: Request) {
code: replayError.code,
})
return NextResponse.redirect(
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
`${returnUrl}&stripe_error=${encodeURIComponent('invalid_state')}`,
)
}
@@ -143,7 +145,7 @@ export async function GET(request: Request) {
})
.eq('id', pendingConnection.id)
return NextResponse.redirect(
`${settingsUrl}?stripe_error=${encodeURIComponent(
`${returnUrl}&stripe_error=${encodeURIComponent(
isConflict ? 'account_already_connected' : 'activation_failed',
)}`,
)
@@ -168,7 +170,7 @@ export async function GET(request: Request) {
})
}
return NextResponse.redirect(`${settingsUrl}?stripe_connected=true`)
return NextResponse.redirect(`${returnUrl}&stripe_connected=true`)
} catch (error) {
console.error('[stripe] Callback error', {
message: error instanceof Error ? error.message : String(error),
@@ -191,7 +193,7 @@ export async function GET(request: Request) {
}
return NextResponse.redirect(
`${settingsUrl}?stripe_error=${encodeURIComponent('connection_failed')}`,
`${returnUrl}&stripe_error=${encodeURIComponent('connection_failed')}`,
)
}
}
@@ -90,6 +90,7 @@ function enqueueHappyPath(opts: {
currency: string
amount_sek?: number | null
cash_account_id?: string | null
document_id?: string | null
}
invoice: {
currency: string
@@ -113,6 +114,7 @@ function enqueueHappyPath(opts: {
amount_sek: opts.transaction.amount_sek ?? null,
supplier_invoice_id: null,
cash_account_id: opts.transaction.cash_account_id ?? null,
document_id: opts.transaction.document_id ?? null,
date: '2026-05-12',
},
error: null,
@@ -630,6 +632,53 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: cash method + FX',
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: transaction document propagation', () => {
const DOC_UUID = '33333333-3333-4333-8333-333333333333'
it('links a pinned transaction document to the payment JE after the match', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK', document_id: DOC_UUID },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
// 8. document_attachments update (the propagation)
enqueue({ data: null, error: null })
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
const tables = mockSupabase.from.mock.calls.map((c) => c[0])
expect(tables).toContain('document_attachments')
})
it('does not touch document_attachments when the transaction has no pinned doc', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
const tables = mockSupabase.from.mock.calls.map((c) => c[0])
expect(tables).not.toContain('document_attachments')
})
it('propagation failure is non-fatal: the committed match still returns 200', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK', document_id: DOC_UUID },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
// Propagation update errors (e.g. period locked between commit and link).
enqueue({ data: null, error: { message: 'BFL period lock' } })
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.stringContaining('failed to link transaction document'),
expect.anything(),
)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: payment JE failure aborts', () => {
// Regression: the route used to catch a JE-creation failure and proceed: // marking the invoice paid with NO payment voucher. That half-state is
// unrecoverable (mark-paid rejects 'paid', match rejects linked txs), so a
@@ -399,6 +399,32 @@ export const POST = withRouteContext(
return errorResponseFromCode('MATCH_SI_LINK_TX_FAILED', txLog, { requestId })
}
// Propagate a document pinned to the transaction (via /attach-document or
// MCP) onto the payment verifikat, mirroring the categorize route (BFL
// 5 kap 6 §: the verifikation must reference its underlag). Guarded to
// unlinked current-version docs only: a doc already serving another
// verifikat (e.g. the supplier invoice's own document on the registration
// entry) must not move. Non-fatal: the match is already committed.
if (transaction.document_id) {
const { error: docLinkError } = await supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', transaction.document_id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_current_version', true)
if (docLinkError) {
// Structured fields so the half-linked state (doc retained but not
// anchored to the payment JE) can be reconstructed without an audit
// trail dig.
txLog.warn('failed to link transaction document to payment JE (non-critical)', {
error: docLinkError,
documentId: transaction.document_id,
journalEntryId,
})
}
}
logMatchEvent(supabase, user.id, transactionId, 'matched', {
supplierInvoiceId: supplier_invoice_id,
matchConfidence: 1.0,
@@ -452,6 +452,31 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Propagate a document pinned to the transaction onto the payment
// verifikat, mirroring the dashboard route (BFL 5 kap 6 §). Guarded to
// unlinked current-version docs only: a doc already serving another
// verifikat (e.g. the supplier invoice's own document on the registration
// entry) must not move. Non-fatal: the match is already committed.
if (transaction.document_id) {
const { error: docLinkErr } = await ctx.supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', transaction.document_id)
.eq('company_id', ctx.companyId!)
.is('journal_entry_id', null)
.eq('is_current_version', true)
if (docLinkErr) {
// Structured fields so the half-linked state (doc retained but not
// anchored to the payment JE) can be reconstructed without an audit
// trail dig.
txLog.warn('failed to link transaction document to payment JE (non-critical)', {
error: docLinkErr,
documentId: transaction.document_id,
journalEntryId,
})
}
}
logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
supplierInvoiceId: supplier_invoice_id,
matchConfidence: 1.0,
+1 -1
View File
@@ -6,7 +6,7 @@ import { CONNECT_CLAUDE_MD } from '@/lib/docs/content/connect-claude'
export const metadata: Metadata = {
title: 'Connect with Claude · accounted API',
description:
'Connect Accounted to Claude (claude.ai, Claude Desktop, Claude Code) via the MCP server: OAuth 2.1 connector or the npx gnubok-mcp stdio bridge.',
'Connect Accounted to Claude (claude.ai, Claude Desktop, Claude Code) via the MCP server: OAuth 2.1 connector or the npx accounted-mcp stdio bridge.',
}
export default function DocsApiConnectClaudePage() {
+1 -1
View File
@@ -2,7 +2,7 @@
"mcpServers": {
"accounted": {
"type": "http",
"url": "https://app.gnubok.se/api/extensions/ext/mcp-server/mcp"
"url": "https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
# Accounted plugin for Claude Code
The official plugin for [Accounted](https://app.gnubok.se), the open-source Swedish bookkeeping platform. Installing it gives Claude two things at once:
The official plugin for [Accounted](https://app.accounted.se), the open-source Swedish bookkeeping platform. Installing it gives Claude two things at once:
1. **The connection**: the Accounted MCP server (90+ bookkeeping tools, resources, and loadable skills) via OAuth. No API key needed.
2. **The flows**: seven short workflow skills that follow the Swedish bookkeeping rhythm. Each one grounds itself in your company's live data, loads the product's Swedish accounting knowledge when it needs it, and stages every write for your approval. Nothing is ever booked without you saying yes.
@@ -26,15 +26,15 @@ Then run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only
| `/accounted:payroll` | Monthly salary run and AGI underlag |
| `/accounted:year-end` | Bokslut, readiness-gated |
The skills are deliberately thin: the deep procedural and regulatory content (month-end checklist, VAT rutor, payroll rules, bokslut law) lives server-side in Accounted and is loaded at need via `gnubok_load_skill`, so it is always in sync with the product and tailored to your company. `gnubok_list_skills` shows everything available.
The skills are deliberately thin: the deep procedural and regulatory content (month-end checklist, VAT rutor, payroll rules, bokslut law) lives server-side in Accounted and is loaded at need via `accounted_load_skill`, so it is always in sync with the product and tailored to your company. `accounted_list_skills` shows everything available.
## How writes work
Every write tool in Accounted stages a **pending operation** with a preview instead of booking directly. Claude shows you the preview; only `gnubok_approve_pending_operation`, after your explicit approval, books it. Period locks and Swedish accounting law (immutable vouchers, balanced entries, sequential voucher numbers) are enforced by the product itself.
Every write tool in Accounted stages a **pending operation** with a preview instead of booking directly. Claude shows you the preview; only `accounted_approve_pending_operation`, after your explicit approval, books it. Period locks and Swedish accounting law (immutable vouchers, balanced entries, sequential voucher numbers) are enforced by the product itself.
## Self-hosted
Point the MCP connection at your own instance instead: remove the bundled server and add your own with `claude mcp add --transport http accounted https://your-host/api/extensions/ext/mcp-server/mcp`, or use the [`gnubok-mcp`](https://www.npmjs.com/package/gnubok-mcp) stdio bridge with a `gnubok_sk_` API key.
Point the MCP connection at your own instance instead: remove the bundled server and add your own with `claude mcp add --transport http accounted "https://your-host/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted"`, or use the [`accounted-mcp`](https://www.npmjs.com/package/accounted-mcp) stdio bridge with your existing Accounted API key.
## Disclaimer
+4 -4
View File
@@ -10,13 +10,13 @@ Work through unbooked bank transactions and receipts and stage correct vouchers.
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing` (accounting method changes how income is booked: faktureringsmetoden credits 1510 via invoices, kontantmetoden books on payment).
2. Find what is unbooked: start from `Accounted://attention`, then discover the listing and categorization tools with `gnubok_search_tools` (for example "uncategorized transactions", "categorize", "upload receipt").
1. If not already done this session, call `accounted_get_agent_briefing` (accounting method changes how income is booked: faktureringsmetoden credits 1510 via invoices, kontantmetoden books on payment).
2. Find what is unbooked: start from `Accounted://attention`, then discover the listing and categorization tools with `accounted_search_tools` (for example "uncategorized transactions", "categorize", "upload receipt").
3. For each item, decide the posting in this order of authority:
1. Explicit mapping rules the company has configured.
2. Observed history in `Accounted://ledger/context`: how THIS company booked this counterparty before (dominant account, VAT treatment, frequency as evidence). Prefer these over textbook answers, but frequency is not permission to auto-post.
3. Only then general knowledge, and for VAT treatment always load it: `gnubok_load_skill("horizontal/swedish-vat")` for deductibility edge cases (representation caps, EU trade, reverse charge). Never answer Swedish VAT from memory.
4. Stage the categorizations, grouped by counterparty so the user can approve in coherent batches. Present each preview with account, VAT treatment, and why. Approve only what the user confirms, via `gnubok_approve_pending_operation`.
3. Only then general knowledge, and for VAT treatment always load it: `accounted_load_skill("horizontal/swedish-vat")` for deductibility edge cases (representation caps, EU trade, reverse charge). Never answer Swedish VAT from memory.
4. Stage the categorizations, grouped by counterparty so the user can approve in coherent batches. Present each preview with account, VAT treatment, and why. Approve only what the user confirms, via `accounted_approve_pending_operation`.
5. If a tool response carries `period_status` locked or closed, stop that item and explain; never work around a period lock.
6. Finish with a short summary in the user's language: booked, skipped and why, what remains, and the next relevant deadline.
+2 -2
View File
@@ -9,13 +9,13 @@ A read-only pass over the books that ends in a short prioritized list. This flow
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing`.
1. If not already done this session, call `accounted_get_agent_briefing`.
2. Read `Accounted://attention`, `Accounted://period/active`, and `Accounted://recent-activity`.
3. Assess, in this order:
- Unbooked backlog: how many items, how old is the oldest?
- Unreconciled bank transactions in the active period.
- Overdue customer invoices and unpaid supplier invoices.
- Unapproved pending operations waiting on the user (`gnubok_list_pending_operations`).
- Unapproved pending operations waiting on the user (`accounted_list_pending_operations`).
- Upcoming deadlines: moms, AGI, F-skatt, bokslut. Use dates from the product, not memorized ones.
- Period status: is a period that should be closed still open?
4. Present a short table in the user's language: finding, severity, and which flow fixes it (`/accounted:bookkeep`, `/accounted:month-close`, `/accounted:vat`, `/accounted:payroll`, `/accounted:year-end`).
+3 -3
View File
@@ -10,9 +10,9 @@ Run the monthly close as a checklist against live data. The authoritative checkl
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing` and read `Accounted://period/active` to confirm which period is being closed.
2. Load the checklist: `gnubok_load_skill("month-end-close")`. Follow it step by step with the company's real numbers.
3. Bank first: if unreconciled transactions exist in the period, load `gnubok_load_skill("bank-reconciliation")` and clear them before anything else. Unbooked items route through the `/accounted:bookkeep` flow.
1. If not already done this session, call `accounted_get_agent_briefing` and read `Accounted://period/active` to confirm which period is being closed.
2. Load the checklist: `accounted_load_skill("month-end-close")`. Follow it step by step with the company's real numbers.
3. Bank first: if unreconciled transactions exist in the period, load `accounted_load_skill("bank-reconciliation")` and clear them before anything else. Unbooked items route through the `/accounted:bookkeep` flow.
4. Work the remaining checklist items (accruals, recurring vouchers, control balances). Every correction is staged and individually approved by the user; use storno-style corrections through the product's tools, never edit posted entries.
5. If the month ends a VAT period, hand over to `/accounted:vat` rather than improvising the momsdeklaration inside this flow.
6. Lock or close the period only as the final step, only after the user explicitly confirms, as its own staged operation.
+3 -3
View File
@@ -10,10 +10,10 @@ Run the monthly salary cycle: verify the salary run, stage the bookings, prepare
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing`. If the company has no employees, say so and stop (an enskild firma owner takes eget uttag, not salary; offer to explain via the loaded skill).
2. Load the knowledge: `gnubok_load_skill("payroll-monthly")` (the monthly procedure) and `gnubok_load_skill("horizontal/swedish-payroll")` for rules (skatteavdrag, arbetsgivaravgifter and age reductions, formaner, karensavdrag, semesterloneskuld).
1. If not already done this session, call `accounted_get_agent_briefing`. If the company has no employees, say so and stop (an enskild firma owner takes eget uttag, not salary; offer to explain via the loaded skill).
2. Load the knowledge: `accounted_load_skill("payroll-monthly")` (the monthly procedure) and `accounted_load_skill("horizontal/swedish-payroll")` for rules (skatteavdrag, arbetsgivaravgifter and age reductions, formaner, karensavdrag, semesterloneskuld).
3. Verify the month's salary run in the product: does it exist, is it complete, are one-off items in (bonus, sick days, formaner, utlagg)? Ask the user about anything the data cannot show.
4. Stage the salary bookings from the run and present the preview: gross, skatteavdrag, arbetsgivaravgifter, net, per the 7xxx account mapping the product produces. The user approves via `gnubok_approve_pending_operation`.
4. Stage the salary bookings from the run and present the preview: gross, skatteavdrag, arbetsgivaravgifter, net, per the 7xxx account mapping the product produces. The user approves via `accounted_approve_pending_operation`.
5. Prepare the AGI underlag and state the filing deadline (from the product's deadline data, not memory). Filing with Skatteverket is the user's action unless the integration is connected.
6. Report in the user's language: booked, AGI status, deadline, anything pending.
+2 -2
View File
@@ -9,7 +9,7 @@ Verify the connection, learn who this company is, and surface what needs attenti
## Flow
1. Call `gnubok_get_agent_briefing`. This is the single source for company facts: entity type (aktiebolag or enskild firma), accounting method (faktureringsmetoden or kontantmetoden), VAT period, employees, and ledger context. Never assume these; the flows below behave differently depending on them.
1. Call `accounted_get_agent_briefing`. This is the single source for company facts: entity type (aktiebolag or enskild firma), accounting method (faktureringsmetoden or kontantmetoden), VAT period, employees, and ledger context. Never assume these; the flows below behave differently depending on them.
- If the call fails with an auth error, the MCP server is not connected yet: tell the user to run `/mcp` and authenticate with Accounted (OAuth consent screen; read-only scopes by default, write scopes are ticked explicitly). Self-hosted users: see the plugin README.
2. Read `Accounted://attention` and `Accounted://period/active`.
3. Present a short orientation in the user's language: company name and form, active fiscal period and its lock status, and the top 3 items needing attention.
@@ -20,7 +20,7 @@ Verify the connection, learn who this company is, and surface what needs attenti
- `/accounted:vat` - prepare the momsdeklaration
- `/accounted:payroll` - monthly salary run and AGI
- `/accounted:year-end` - bokslut
5. Mention that deeper, company-tailored guides exist on the server: `gnubok_list_skills` lists them (workflow guides plus Swedish regulatory skills, filtered to this company), and `gnubok_load_skill(slug)` loads any of them.
5. Mention that deeper, company-tailored guides exist on the server: `accounted_list_skills` lists them (workflow guides plus Swedish regulatory skills, filtered to this company), and `accounted_load_skill(slug)` loads any of them.
## Rules
+3 -3
View File
@@ -10,10 +10,10 @@ Prepare the momsdeklaration underlag from the ledger and reconcile it before any
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing`: it gives VAT registration status, period length (monthly, quarterly, yearly), and accounting method. If the company is not VAT registered, say so and stop.
2. Load the knowledge: `gnubok_load_skill("quarterly-vat-review")` (the review procedure, valid for any period length) and `gnubok_load_skill("horizontal/swedish-vat")` for ruta mapping and edge cases (reverse charge, EU trade, import VAT, representation).
1. If not already done this session, call `accounted_get_agent_briefing`: it gives VAT registration status, period length (monthly, quarterly, yearly), and accounting method. If the company is not VAT registered, say so and stop.
2. Load the knowledge: `accounted_load_skill("quarterly-vat-review")` (the review procedure, valid for any period length) and `accounted_load_skill("horizontal/swedish-vat")` for ruta mapping and edge cases (reverse charge, EU trade, import VAT, representation).
3. Verify the period is fully booked first: any unbooked transactions in the period make the return wrong. Route gaps through `/accounted:bookkeep` before continuing.
4. Generate the VAT report with the product's tools (discover via `gnubok_search_tools`, for example "vat report momsdeklaration") and reconcile: report rutor against the 26xx account balances, and against the previous period for anomalies.
4. Generate the VAT report with the product's tools (discover via `accounted_search_tools`, for example "vat report momsdeklaration") and reconcile: report rutor against the 26xx account balances, and against the previous period for anomalies.
5. Present a per-ruta summary in the user's language, flagging anything unusual with the ledger evidence behind it.
6. Booking the VAT settlement (redovisning mot 2650/1650) is a staged operation the user approves. Filing with Skatteverket is the user's action: prepare the underlag, state the deadline, and where the product's Skatteverket integration is connected, point at it.
+5 -5
View File
@@ -10,12 +10,12 @@ Run the bokslut as a readiness-gated, step-by-step flow. This is the highest-sta
## Flow
1. If not already done this session, call `gnubok_get_agent_briefing`: entity type decides the whole shape (aktiebolag: bolagsskatt and resultatdisposition; enskild firma: NE-bilaga, egenavgifter, rantefordelning).
2. Readiness first: call `gnubok_year_end_readiness` (read-only preflight). Every blocker it reports is fixed through the other flows (`/accounted:bookkeep`, `/accounted:month-close`, `/accounted:vat`) before continuing. Do not start closing entries on a year that is not ready.
3. Load the knowledge: `gnubok_load_skill("year-end-close")` (the procedure) and `gnubok_load_skill("horizontal/swedish-year-end-closing")` (the law and the account-level detail). When the user wants to optimize (periodiseringsfond, overavskrivningar), also load `gnubok_load_skill("horizontal/swedish-tax-planning")` and present options with trade-offs, not a single answer.
1. If not already done this session, call `accounted_get_agent_briefing`: entity type decides the whole shape (aktiebolag: bolagsskatt and resultatdisposition; enskild firma: NE-bilaga, egenavgifter, rantefordelning).
2. Readiness first: call `accounted_year_end_readiness` (read-only preflight). Every blocker it reports is fixed through the other flows (`/accounted:bookkeep`, `/accounted:month-close`, `/accounted:vat`) before continuing. Do not start closing entries on a year that is not ready.
3. Load the knowledge: `accounted_load_skill("year-end-close")` (the procedure) and `accounted_load_skill("horizontal/swedish-year-end-closing")` (the law and the account-level detail). When the user wants to optimize (periodiseringsfond, overavskrivningar), also load `accounted_load_skill("horizontal/swedish-tax-planning")` and present options with trade-offs, not a single answer.
4. Work the bokslutstransaktioner in the loaded order, one staged operation at a time, each approved by the user with the amounts and accounts visible.
5. The final close (`gnubok_run_year_end`) is high-risk: run it only after readiness is green again and the user has explicitly confirmed in this conversation.
6. Report what remains outside the ledger: arsredovisning and filing for AB (load `gnubok_load_skill("horizontal/swedish-financial-reporting")` if asked), INK2 or NE underlag, deadlines from the product's data.
5. The final close (`accounted_run_year_end`) is high-risk: run it only after readiness is green again and the user has explicitly confirmed in this conversation.
6. Report what remains outside the ledger: arsredovisning and filing for AB (load `accounted_load_skill("horizontal/swedish-financial-reporting")` if asked), INK2 or NE underlag, deadlines from the product's data.
## Rules
@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const StripePanel = getSettingsPanel('stripe')
export function PaymentsSettingsContent() {
const t = useTranslations('settings_payments')
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
return (
<div className="space-y-8">
{hasStripeExtension && StripePanel ? (
<StripePanel />
) : (
<Card>
<CardContent className="p-0">
<EmptyState
icon={CreditCard}
title={t('not_enabled_title')}
description={t('not_enabled_description')}
>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
{t('go_to_extensions')}
</Link>
</Button>
</EmptyState>
</CardContent>
</Card>
)}
</div>
)
}
-5
View File
@@ -30,10 +30,6 @@ const TemplatesSettingsContent = dynamic(() =>
import('./TemplatesSettingsContent').then((module) => ({ default: module.TemplatesSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const PaymentsSettingsContent = dynamic(() =>
import('./PaymentsSettingsContent').then((module) => ({ default: module.PaymentsSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const BankingSettingsContent = dynamic(() =>
import('./BankingSettingsContent').then((module) => ({ default: module.BankingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
@@ -66,7 +62,6 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
salary: SalarySettingsContent,
invoicing: InvoicingSettingsContent,
templates: TemplatesSettingsContent,
payments: PaymentsSettingsContent,
banking: BankingSettingsContent,
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
@@ -41,7 +41,6 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under
@@ -57,7 +56,6 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
// with staff. #782
{ id: 'salary', href: '/settings/salary', label: t('salary'), group: 'accounting', show: hasCompany && (company?.entity_type === 'aktiebolag' || !!company?.pays_salaries) },
{ id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany },
{ id: 'payments', href: '/settings/payments', label: t('payments'), group: 'sales', show: hasCompany && !isSandbox && hasStripeExtension },
{ id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany },
{ id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension },
{ id: 'assistant', href: '/settings/assistant', label: t('assistant'), group: 'tools', show: hasCompany && identity.isVerified },
@@ -47,8 +47,8 @@ export default function SkattekontoInboxCard({
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
@@ -76,7 +76,7 @@ export default function SkattekontoInboxCard({
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-3">
{matchSuggestion ? (
<>
@@ -189,15 +189,17 @@ export default function TransactionHistoryList({
description={searchTerm ? t('empty_search') : t('empty_filter')}
/>
) : (
<div className="overflow-x-auto">
/* Negative margin + matching padding: keeps the columns flush with
the page edges (mirrors the inbox table on the transactions page). */
<div className="-mx-5 overflow-x-auto px-5 md:-mx-8 md:px-8">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-0 !p-0')} aria-hidden="true"></th>
<th className={cn(TH_CLASS, '!pl-0')}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
<th className={cn(TH_CLASS, 'text-right !pr-0')}>{t('th_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
@@ -303,8 +305,8 @@ function BankHistoryRow({
data-tx-id={transaction.id}
className="group transition-colors duration-150 hover:bg-secondary/35"
>
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
@@ -355,7 +357,7 @@ function BankHistoryRow({
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
@@ -389,10 +391,12 @@ function BankHistoryRow({
{showOverflowMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* mr-2 tucks the button in so the dots glyph sits under
the middle of the STATUS header, not at the page edge. */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className="mr-2 h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label="Fler alternativ"
>
<MoreHorizontal className="h-4 w-4" />
@@ -467,8 +471,8 @@ function SkattekontoHistoryRow({
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
@@ -495,7 +499,7 @@ function SkattekontoHistoryRow({
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
@@ -258,8 +258,10 @@ export default function TransactionInboxCard({
}
>
{/* Hover-revealed selection checkbox (concept .cb) */}
{/* Zero-width cell: the checkbox hangs in the left page margin so
the date column can sit flush with the page edge. */}
<td
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
className={cn(TD_CLASS, 'relative w-0 !p-0')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
@@ -268,7 +270,7 @@ export default function TransactionInboxCard({
onCheckedChange={() => onToggleSelect(transaction.id)}
aria-label="Välj transaktion"
className={cn(
'transition-opacity duration-150',
'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
@@ -276,7 +278,7 @@ export default function TransactionInboxCard({
/>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
@@ -309,7 +311,7 @@ export default function TransactionInboxCard({
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<td className={cn(TD_CLASS, 'relative whitespace-nowrap text-right !pr-0 py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
<Button
size="sm"
@@ -327,10 +329,12 @@ export default function TransactionInboxCard({
{showOverflowMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* mr-2 tucks the button in so the dots glyph sits under
the middle of the STATUS header, not at the page edge. */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className="mr-2 h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
aria-label={t('more_actions_aria')}
title={t('more_actions_aria')}
@@ -435,15 +439,15 @@ export default function TransactionInboxCard({
</DropdownMenuContent>
</DropdownMenu>
)}
{canExpand ? (
{/* Expand affordance hangs in the right page margin, mirroring
the selection checkbox on the left. */}
{canExpand && (
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-all duration-200',
'absolute -right-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground transition-all duration-200 md:-right-6',
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
/>
) : (
<span className="h-3.5 w-3.5" aria-hidden />
)}
</span>
</td>
@@ -452,7 +456,7 @@ export default function TransactionInboxCard({
<tr>
<td colSpan={5} className="border-b border-border p-0">
<RowFoldout>
<div className="px-1 pb-6 pt-1 sm:pl-9 sm:pr-4">
<div className="pb-6 pt-1">
{(transaction.currency !== 'SEK' && transaction.amount_sek != null) ||
transaction.title_edited_at ||
skvCounterpartDate ? (
+1 -1
View File
@@ -1,6 +1,6 @@
# Accounted MCP server
JSON-RPC 2.0 server exposing the Accounted bookkeeping engine to MCP clients (Claude Desktop, Claude Code, etc.). Endpoint: `/api/extensions/ext/mcp-server/mcp`. OAuth and stdio bridge live alongside the API surface: see `app/api/mcp-oauth/` and `packages/gnubok-mcp/`.
JSON-RPC 2.0 server exposing the Accounted bookkeeping engine to MCP clients (Claude Desktop, Claude Code, etc.). Endpoint: `/api/extensions/ext/mcp-server/mcp`. Add `?tool_namespace=accounted` for the Accounted tool names. Requests without it retain the legacy Gnubok namespace. OAuth and stdio bridges live alongside the API surface: see `app/api/mcp-oauth/`, `packages/accounted-mcp/`, and the compatibility package in `packages/gnubok-mcp/`.
## Tool authoring contract
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { workflowSkills } from '../skills'
import { dataResources } from '../resources'
import { toCanonicalToolName } from '../tool-namespace'
import { discoverAtoms } from '@/scripts/lib/atom-discovery'
/**
@@ -38,11 +39,11 @@ describe('claude-plugin wrapper references', () => {
])
})
it('every gnubok_load_skill slug resolves to a workflow skill or a registry atom', async () => {
it('every accounted_load_skill slug resolves to a workflow skill or a registry atom', async () => {
const workflowSlugs = new Set(workflowSkills.map((s) => s.slug))
const atomIds = new Set((await discoverAtoms(repoRoot)).map((a) => a.id))
for (const { file, body } of pluginSkills) {
for (const [, slug] of body.matchAll(/gnubok_load_skill\("([^"]+)"\)/g)) {
for (const [, slug] of body.matchAll(/accounted_load_skill\("([^"]+)"\)/g)) {
const known = workflowSlugs.has(slug) || atomIds.has(slug)
expect(known, `${file} references unknown skill slug "${slug}"`).toBe(true)
}
@@ -58,11 +59,12 @@ describe('claude-plugin wrapper references', () => {
}
})
it('every gnubok_* tool name exists on the server', () => {
it('every accounted_* tool name resolves to a canonical server tool', () => {
for (const { file, body } of pluginSkills) {
for (const [tool] of body.matchAll(/gnubok_[a-z_]+/g)) {
for (const [tool] of body.matchAll(/accounted_[a-z_]+/g)) {
const canonicalTool = toCanonicalToolName(tool)
expect(
serverSource.includes(`name: '${tool}'`),
serverSource.includes(`name: '${canonicalTool}'`),
`${file} references unknown tool "${tool}"`,
).toBe(true)
}
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
const mocks = vi.hoisted(() => ({
scopes: [] as string[],
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
mocks.scopes = [...actual.ALL_SCOPES]
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: mocks.scopes,
apiKeyId: 'key-1',
apiKeyName: 'Test key',
mode: 'live',
}),
createServiceClientNoCookies: vi.fn(() => ({})),
}
})
import { handleMcpRequest, tools as canonicalTools } from '../server'
import { toPublicToolName } from '../tool-namespace'
function mcpRequest(
method: string,
params?: Record<string, unknown>,
namespace?: 'accounted'
): Request {
const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp')
if (namespace) url.searchParams.set('tool_namespace', namespace)
return new Request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
...(params ? { params } : {}),
}),
})
}
async function readResult(request: Request): Promise<Record<string, unknown>> {
const response = await handleMcpRequest(request)
const body = await response.json()
return body.result as Record<string, unknown>
}
describe('MCP namespace compatibility', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('maps every canonical tool to one valid, unique Accounted name', () => {
const accountedNames = canonicalTools.map((tool) =>
toPublicToolName(tool.name, 'accounted')
)
expect(new Set(accountedNames).size).toBe(canonicalTools.length)
for (const name of accountedNames) {
expect(name).toMatch(/^[A-Za-z0-9_.\-/]{1,64}$/)
expect(name).toMatch(/^accounted_/)
}
})
it('keeps the legacy server identity and tool catalog by default', async () => {
const initialized = await readResult(
mcpRequest('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
})
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('gnubok')
expect(initialized.instructions).toContain('gnubok_search_tools')
const listed = await readResult(mcpRequest('tools/list'))
const tools = listed.tools as Array<{ name: string }>
expect(tools.length).toBeGreaterThan(0)
expect(tools.every((tool) => tool.name.startsWith('gnubok_'))).toBe(true)
})
it('advertises the Accounted identity, tools, and staging references when selected', async () => {
const initialized = await readResult(
mcpRequest(
'initialize',
{
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
},
'accounted'
)
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('accounted')
expect(initialized.instructions).toContain('accounted_search_tools')
const listed = await readResult(mcpRequest('tools/list', undefined, 'accounted'))
const tools = listed.tools as Array<{
name: string
_meta?: { approve_tool?: string; preflight?: string }
}>
expect(tools.length).toBeGreaterThan(0)
expect(tools.every((tool) => tool.name.startsWith('accounted_'))).toBe(true)
const canonicalNames = new Set(canonicalTools.map((tool) => tool.name))
const leakedReferences =
JSON.stringify(tools).match(/\bgnubok_[A-Za-z0-9_]+\b/g)?.filter((name) =>
canonicalNames.has(name)
) ?? []
expect(leakedReferences).toEqual([])
const stagingTool = tools.find((tool) => tool._meta?.approve_tool)
expect(stagingTool?._meta?.approve_tool).toBe(
'accounted_approve_pending_operation'
)
if (stagingTool?._meta?.preflight) {
expect(stagingTool._meta.preflight).toMatch(/^accounted_/)
}
})
it('accepts both aliases while returning the selected public namespace', async () => {
const params = {
arguments: {
query: 'list companies',
detail: 'name',
limit: 10,
},
}
const accountedCall = await readResult(
mcpRequest(
'tools/call',
{ ...params, name: 'accounted_search_tools' },
'accounted'
)
)
const legacyAliasCall = await readResult(
mcpRequest(
'tools/call',
{ ...params, name: 'gnubok_search_tools' },
'accounted'
)
)
expect(accountedCall.structuredContent).toEqual(
legacyAliasCall.structuredContent
)
const structured = accountedCall.structuredContent as {
tools: Array<{ name: string }>
}
expect(structured.tools.length).toBeGreaterThan(0)
expect(
structured.tools.every((tool) => tool.name.startsWith('accounted_'))
).toBe(true)
})
it('projects prompt and loaded-skill tool references for Accounted clients', async () => {
const prompt = await readResult(
mcpRequest(
'prompts/get',
{ name: 'cash_today' },
'accounted'
)
)
const messages = prompt.messages as Array<{
content: { text: string }
}>
expect(messages[0].content.text).toContain('accounted_get_balance_sheet')
expect(messages[0].content.text).not.toContain('gnubok_get_balance_sheet')
const loaded = await readResult(
mcpRequest(
'tools/call',
{
name: 'accounted_load_skill',
arguments: { slug: 'month-end-close' },
},
'accounted'
)
)
const structured = loaded.structuredContent as { body: string }
expect(structured.body).toContain('accounted_list_uncategorized_transactions')
expect(structured.body).not.toContain('gnubok_list_uncategorized_transactions')
})
})
@@ -241,6 +241,32 @@ describe('mcp.tool_called telemetry', () => {
expect(event.latencyMs).toBe(0)
})
it('applies the canonical scope gate to an Accounted alias', async () => {
const eventPromise = captureNextToolCalledEvent()
const response = await handleMcpRequest(
mcpRequest(
'tools/call',
{
name: 'accounted_create_invoice',
arguments: { customer_id: 'x', items: [] },
},
1,
{
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted',
}
)
)
const body = await response.json()
const payload = JSON.parse(body.result.content[0].text)
expect(payload.error.code).toBe('INSUFFICIENT_SCOPE')
const event = await eventPromise
expect(event.tool).toBe('gnubok_create_invoice')
expect(event.requiredScope).toBe('invoices:write')
expect(event.errorKind).toBe('scope_denied')
})
it('emits errorKind=unknown_tool when the tool name does not exist', async () => {
const eventPromise = captureNextToolCalledEvent()
@@ -325,6 +351,21 @@ describe('client marker telemetry', () => {
expect(event.client).toBe('openclaw')
})
it('records the Accounted client header on the same telemetry field', async () => {
const eventPromise = captureNextToolCalledEvent()
await handleMcpRequest(
mcpRequest('tools/call', { name: 'accounted_list_skills', arguments: {} }, 1, {
url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted',
headers: { 'X-Accounted-Client': 'Claude-Desktop' },
})
)
const event = await eventPromise
expect(event.client).toBe('claude-desktop')
expect(event.tool).toBe('gnubok_list_skills')
})
it('falls back to the ?client= query param when no header is present', async () => {
const eventPromise = captureNextToolsListEvent()
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
canonicalizeToolReferencesInText,
projectToolReferences,
resolveMcpToolNamespace,
toCanonicalToolName,
toPublicToolName,
} from '../tool-namespace'
const registered = new Set([
'gnubok_search_tools',
'gnubok_list_companies',
'gnubok_approve_pending_operation',
])
describe('MCP tool namespaces', () => {
it('keeps the legacy namespace unless Accounted is explicitly requested', () => {
expect(resolveMcpToolNamespace(new Request('https://example.test/mcp'))).toBe('gnubok')
expect(
resolveMcpToolNamespace(
new Request('https://example.test/mcp?tool_namespace=unknown')
)
).toBe('gnubok')
expect(
resolveMcpToolNamespace(
new Request('https://example.test/mcp?tool_namespace=accounted')
)
).toBe('accounted')
})
it('maps Accounted aliases to canonical tool names', () => {
expect(toCanonicalToolName('accounted_list_companies')).toBe(
'gnubok_list_companies'
)
expect(toCanonicalToolName('gnubok_list_companies')).toBe(
'gnubok_list_companies'
)
expect(toPublicToolName('gnubok_list_companies', 'accounted')).toBe(
'accounted_list_companies'
)
expect(toPublicToolName('gnubok_list_companies', 'gnubok')).toBe(
'gnubok_list_companies'
)
})
it('canonicalizes Accounted references in search queries', () => {
expect(
canonicalizeToolReferencesInText(
'Find accounted_list_companies and accounted_search_tools'
)
).toBe('Find gnubok_list_companies and gnubok_search_tools')
})
it('projects only registered tool references and preserves wire identifiers', () => {
const result = projectToolReferences(
{
message:
'Call gnubok_list_companies, then gnubok_approve_pending_operation.',
key: 'gnubok_sk_test_example',
unknown: 'gnubok_not_a_registered_tool',
next: { tool: 'gnubok_search_tools' },
},
'accounted',
registered
)
expect(result).toEqual({
message:
'Call accounted_list_companies, then accounted_approve_pending_operation.',
key: 'gnubok_sk_test_example',
unknown: 'gnubok_not_a_registered_tool',
next: { tool: 'accounted_search_tools' },
})
})
})
+1 -1
View File
@@ -13,6 +13,6 @@
"dataPattern": "manual",
"hasOwnData": false,
"description": "Gör bokföring via Claude, Cursor eller annan MCP-klient",
"longDescription": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
"longDescription": "Exponerar Accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
}
}
+167 -65
View File
@@ -37,6 +37,15 @@ import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from './recommended-tools'
import {
canonicalizeToolReferencesInText,
projectToolReferences,
projectToolReferencesInText,
resolveMcpToolNamespace,
toCanonicalToolName,
toPublicToolName,
type McpToolNamespace,
} from './tool-namespace'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
@@ -146,9 +155,10 @@ interface ActorContext {
*/
sessionId?: string | null
/**
* Distribution-channel marker from the `X-Gnubok-Client` header or the
* `client` query param on the endpoint URL (e.g. 'openclaw'). Telemetry-only:
* same trust level as Mcp-Session-Id, never used for auth or behavior.
* Distribution-channel marker from `X-Accounted-Client`, the legacy
* `X-Gnubok-Client`, or the `client` query param (e.g. 'openclaw').
* Telemetry-only: same trust level as Mcp-Session-Id, never used for auth or
* behavior.
*/
client?: string | null
}
@@ -1683,6 +1693,17 @@ const DIMENSION_FILTER_OUTPUT_PROPS = {
},
} as const
let canonicalToolNamesCache: ReadonlySet<string> | undefined
function getCanonicalToolNames(): ReadonlySet<string> {
canonicalToolNamesCache ??= new Set(tools.map((tool) => tool.name))
return canonicalToolNamesCache
}
function projectMcpPayload<T>(value: T, namespace: McpToolNamespace): T {
return projectToolReferences(value, namespace, getCanonicalToolNames())
}
// ── Tools ────────────────────────────────────────────────────
export const tools: McpTool[] = [
@@ -1718,7 +1739,11 @@ export const tools: McpTool[] = [
openWorldHint: false,
},
async execute(args, _companyId, _userId, _supabase, _actor) {
const query = ((args.query as string) || '').toLowerCase().trim()
const namespace: McpToolNamespace =
args.__toolNamespace === 'accounted' ? 'accounted' : 'gnubok'
const query = canonicalizeToolReferencesInText(
((args.query as string) || '').toLowerCase().trim()
)
const detail = ((args.detail as string) || 'summary') as 'name' | 'summary' | 'full'
const scopeFilter = args.scope as string | undefined
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
@@ -1781,21 +1806,36 @@ export const tools: McpTool[] = [
const projected = sliced.map((t) => {
const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null
if (detail === 'name') return { name: t.name, scope: requiredScope }
if (detail === 'name') {
return { name: toPublicToolName(t.name, namespace), scope: requiredScope }
}
if (detail === 'full') {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
description: t.description,
scope: requiredScope,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
const meta = projectMcpPayload(
{ ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) },
namespace
)
return projectMcpPayload(
{
name: toPublicToolName(t.name, namespace),
description: t.description,
scope: requiredScope,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
},
namespace
)
}
// summary (default)
return { name: t.name, description: t.description, scope: requiredScope }
return projectMcpPayload(
{
name: toPublicToolName(t.name, namespace),
description: t.description,
scope: requiredScope,
},
namespace
)
})
return {
@@ -13614,14 +13654,19 @@ assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name)))
// ── MCP Protocol Handler ─────────────────────────────────────
const SERVER_INFO = {
// `name` is a stable identifier clients may key state on: stays 'gnubok'
// per the rebrand rule. `title` is the human-readable display name
// (MCP spec 2025-06-18).
name: 'gnubok',
title: 'Accounted',
version: '1.0.0',
}
const SERVER_INFO_BY_NAMESPACE = {
gnubok: {
// Stable legacy identity for every existing connection.
name: 'gnubok',
title: 'Accounted',
version: '1.0.0',
},
accounted: {
name: 'accounted',
title: 'Accounted',
version: '1.0.0',
},
} as const
const PROTOCOL_VERSION = '2025-06-18'
@@ -13891,8 +13936,13 @@ function emitWorkflowStarted(payload: {
* Auth is done via Bearer API key (extension route has skipAuth: true).
*/
export async function handleMcpRequest(request: Request): Promise<Response> {
const toolNamespace = resolveMcpToolNamespace(request)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const wwwAuth = `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"`
const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', appUrl)
if (toolNamespace === 'accounted') {
resourceMetadataUrl.searchParams.set('tool_namespace', 'accounted')
}
const wwwAuth = `Bearer resource_metadata="${resourceMetadataUrl.toString()}"`
// ── Pre-auth: handle fire-and-forget notifications before auth check ──
// MCP notifications have no id and don't expect error responses.
@@ -13939,12 +13989,13 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// followed metric. It is NOT used for auth.
const rawSessionId = request.headers.get('mcp-session-id')
const sessionId = rawSessionId && /^[A-Za-z0-9_-]{1,128}$/.test(rawSessionId) ? rawSessionId : null
// Distribution-channel marker: `X-Gnubok-Client` header (gnubok-mcp bridge
// ≥1.1 forwards GNUBOK_CLIENT) or a `client` query param on the endpoint URL
// (works with any bridge version and direct HTTP clients). Lets us measure
// per-channel adoption (e.g. an OpenClaw skill) in event_log without auth
// implications.
const rawClient = request.headers.get('x-gnubok-client') ?? new URL(request.url).searchParams.get('client')
// Distribution-channel marker: the Accounted bridge sends
// `X-Accounted-Client`; the legacy bridge keeps `X-Gnubok-Client`. Both are
// telemetry-only and share the same validation and storage path.
const rawClient =
request.headers.get('x-accounted-client') ??
request.headers.get('x-gnubok-client') ??
new URL(request.url).searchParams.get('client')
const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null
const actor: ActorContext = {
type: 'api_key',
@@ -13989,8 +14040,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
resources: { listChanged: false },
prompts: { listChanged: false },
},
serverInfo: SERVER_INFO,
instructions: [
serverInfo: SERVER_INFO_BY_NAMESPACE[toolNamespace],
instructions: projectToolReferencesInText([
'Accounted: Swedish double-entry bookkeeping via conversation.',
'',
'Discovery:',
@@ -14017,8 +14068,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.',
'Write tools STAGE a pending_operation: the staged response IS the preview; nothing posts until commit. A tool whose tools/list `_meta.requires_approval` is true stages for approval; `_meta.preflight` (when present) names a read-only check to run first (e.g. gnubok_year_end_readiness before gnubok_run_year_end, gnubok_vat_declaration_validate before _submit). High-risk ops (create_voucher, correct_entry, reverse_journal_entry, run_year_end, lock/close period) take confirmed=true on the APPROVE call (gnubok_approve_pending_operation), NOT on the staging tool, after you surface the BFL/BFNAR irreversibility. Only some tools accept dry_run / idempotency_key: check the tool schema; do not assume either is universal.',
'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").',
'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.',
].join('\n'),
toolNamespace === 'gnubok'
? 'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.'
: 'Tool names use the accounted_ prefix. Legacy gnubok_ aliases remain accepted for existing integrations.',
].join('\n'), toolNamespace, getCanonicalToolNames()),
})
)
}
@@ -14051,23 +14104,32 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// Merge derived staging metadata with any literal _meta (e.g. UI
// widget hints). Literal _meta wins on key collision so explicit
// tool config is never clobbered.
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
const meta = projectMcpPayload(
{ ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) },
toolNamespace
)
return projectMcpPayload(
{
name: toPublicToolName(t.name, toolNamespace),
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
},
toolNamespace
)
}),
})
)
}
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const rawRequestedToolName = (params as Record<string, unknown>)?.name
const requestedToolName =
typeof rawRequestedToolName === 'string' ? rawRequestedToolName : ''
const toolName = toCanonicalToolName(requestedToolName)
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
@@ -14092,9 +14154,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
userId,
companyId,
})
const available = tools.map((t) => t.name).join(', ')
const available = tools
.map((t) => toPublicToolName(t.name, toolNamespace))
.join(', ')
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". Available tools: ${available}`)
jsonRpcError(
id ?? null,
-32602,
`Unknown tool: "${requestedToolName}". Available tools: ${available}`
)
)
}
@@ -14119,9 +14187,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
userId,
companyId,
})
const publicScopeError = projectMcpPayload(scopeError, toolNamespace)
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicScopeError, null, 2) }],
isError: true,
})
)
@@ -14146,6 +14215,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
} catch (err) {
const structured = toToolError(err, { toolName })
const publicStructured = projectMcpPayload(structured, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14164,7 +14234,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }],
isError: true,
})
)
@@ -14177,6 +14247,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
const publicCapError = projectMcpPayload(capError, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope,
@@ -14193,7 +14264,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(capError, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicCapError, null, 2) }],
isError: true,
})
)
@@ -14217,6 +14288,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
),
{ toolName }
)
const publicBlocked = projectMcpPayload(blocked, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14233,7 +14305,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(blocked, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicBlocked, null, 2) }],
isError: true,
})
)
@@ -14251,9 +14323,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// what the API key can actually invoke. Inject privately via __keyScopes.
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
;(toolArgs as Record<string, unknown>).__toolNamespace = toolNamespace
}
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const result = projectMcpPayload(canonicalResult, toolNamespace)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -14273,8 +14347,12 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
// Record the response's `next.tool` (when present) so the next call
// from the same session can be matched against it.
if (result && typeof result === 'object' && !Array.isArray(result)) {
const next = (result as Record<string, unknown>).next
if (
canonicalResult &&
typeof canonicalResult === 'object' &&
!Array.isArray(canonicalResult)
) {
const next = (canonicalResult as Record<string, unknown>).next
if (next && typeof next === 'object') {
const suggestedTool = (next as Record<string, unknown>).tool
if (typeof suggestedTool === 'string') {
@@ -14300,6 +14378,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
} catch (err) {
const latencyMs = Date.now() - callStartedAt
const structured = toToolError(err, { toolName })
const publicStructured = projectMcpPayload(structured, toolNamespace)
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
@@ -14319,7 +14398,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }],
isError: true,
})
)
@@ -14329,7 +14408,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'resources/list': {
const allSkills = await loadAllSkills(supabase)
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, projectMcpPayload({
resources: [
...uiWidgets.map((w) => ({
uri: w.uri,
@@ -14350,7 +14429,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
mimeType: r.mimeType,
})),
],
})
}, toolNamespace))
)
}
@@ -14377,7 +14456,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: WIDGET_MIME_TYPE,
text: widget.html,
text: projectToolReferencesInText(
widget.html,
toolNamespace,
getCanonicalToolNames()
),
},
],
})
@@ -14408,7 +14491,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: SKILL_MIME_TYPE,
text: skill.body,
text: projectToolReferencesInText(
skill.body,
toolNamespace,
getCanonicalToolNames()
),
},
],
})
@@ -14443,7 +14530,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
{
uri,
mimeType: dataResource.mimeType,
text: JSON.stringify(result, null, 2),
text: JSON.stringify(projectMcpPayload(result, toolNamespace), null, 2),
},
],
})
@@ -14462,7 +14549,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
companyId,
})
return NextResponse.json(
jsonRpcError(id ?? null, -32603, `Resource read error: ${message}`)
jsonRpcError(
id ?? null,
-32603,
projectToolReferencesInText(
`Resource read error: ${message}`,
toolNamespace,
getCanonicalToolNames()
)
)
)
}
}
@@ -14485,12 +14580,12 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'prompts/list':
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, projectMcpPayload({
prompts: prompts.map((p) => ({
name: p.name,
description: p.description,
})),
})
}, toolNamespace))
)
case 'prompts/get': {
@@ -14507,7 +14602,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
messages: [
{
role: 'user',
content: { type: 'text', text: prompt.text },
content: {
type: 'text',
text: projectToolReferencesInText(
prompt.text,
toolNamespace,
getCanonicalToolNames()
),
},
},
],
})
@@ -0,0 +1,73 @@
export type McpToolNamespace = 'gnubok' | 'accounted'
export const TOOL_NAMESPACE_QUERY_PARAM = 'tool_namespace'
const LEGACY_TOOL_PREFIX = 'gnubok_'
const ACCOUNTED_TOOL_PREFIX = 'accounted_'
const LEGACY_TOOL_REFERENCE_RE = /\bgnubok_[A-Za-z0-9_]+\b/g
const ACCOUNTED_TOOL_REFERENCE_RE = /\baccounted_[A-Za-z0-9_]+\b/g
export function resolveMcpToolNamespace(request: Request): McpToolNamespace {
const requested = new URL(request.url).searchParams.get(TOOL_NAMESPACE_QUERY_PARAM)
return requested === 'accounted' ? 'accounted' : 'gnubok'
}
export function toCanonicalToolName(name: string): string {
if (!name.startsWith(ACCOUNTED_TOOL_PREFIX)) return name
return `${LEGACY_TOOL_PREFIX}${name.slice(ACCOUNTED_TOOL_PREFIX.length)}`
}
export function toPublicToolName(name: string, namespace: McpToolNamespace): string {
if (namespace !== 'accounted' || !name.startsWith(LEGACY_TOOL_PREFIX)) return name
return `${ACCOUNTED_TOOL_PREFIX}${name.slice(LEGACY_TOOL_PREFIX.length)}`
}
export function canonicalizeToolReferencesInText(text: string): string {
return text.replace(ACCOUNTED_TOOL_REFERENCE_RE, (name) => toCanonicalToolName(name))
}
export function projectToolReferencesInText(
text: string,
namespace: McpToolNamespace,
canonicalToolNames: ReadonlySet<string>,
): string {
if (namespace === 'gnubok') return text
return text.replace(LEGACY_TOOL_REFERENCE_RE, (name) =>
canonicalToolNames.has(name) ? toPublicToolName(name, namespace) : name
)
}
/**
* Project server-owned MCP payloads into the selected public namespace.
*
* Only exact references to registered tool names are rewritten. Wire-format
* identifiers such as gnubok_sk_ API keys therefore remain unchanged.
*/
export function projectToolReferences<T>(
value: T,
namespace: McpToolNamespace,
canonicalToolNames: ReadonlySet<string>,
): T {
if (namespace === 'gnubok') return value
if (typeof value === 'string') {
return projectToolReferencesInText(value, namespace, canonicalToolNames) as T
}
if (Array.isArray(value)) {
return value.map((item) =>
projectToolReferences(item, namespace, canonicalToolNames)
) as T
}
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
projectToolReferences(item, namespace, canonicalToolNames),
])
) as T
}
return value
}
@@ -11,6 +11,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { NotificationType } from '@/types'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { sendNotificationToUser } from './notification-sender'
import {
createTaxDeadlinePayload,
@@ -233,39 +234,115 @@ export async function sendMissingUnderlagNotifications(
let sent = 0
let skipped = 0
// Get all users who have posted entries with source types that need docs
const { data: entries } = await supabase
.from('journal_entries')
.select('id, user_id')
.eq('status', 'posted')
.in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES)
// Get all users who have posted entries with source types that need docs.
// This is a GLOBAL cron over every company, so each read below must page
// past PostgREST's 1000-row cap: a truncated read here would under-count
// candidates, and a truncated docs/reference read would over-count missing
// underlag, producing false "saknade underlag" notifications.
const entries = await fetchAllRows<{ id: string; user_id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, user_id')
.eq('status', 'posted')
.in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES)
.order('id')
.range(from, to)
)
if (!entries || entries.length === 0) {
if (entries.length === 0) {
return { sent: 0, skipped: 0 }
}
// Get all document_attachments linked to journal entries
const { data: attachments } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
const entriesWithDocs = new Set(
(attachments || []).map((a) => a.journal_entry_id)
const attachments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
.order('id')
.range(from, to)
)
const entriesWithDocs = new Set(attachments.map((a) => a.journal_entry_id))
// BFL 5 kap 7 § hänvisning: entries referenced by a supplier invoice whose
// document is retained and anchored to a journal entry count as covered
// (mirrors the verifikat_without_documents RPC): typically the payment
// verifikat, whose invoice document hangs on the registration verifikat.
const siRefs = await fetchAllRows<{
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select(
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)'
)
.not('document_id', 'is', null)
.order('id')
.range(from, to) as unknown as PromiseLike<{
data: {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[] | null
error: { message: string } | null
}>
)
for (const si of siRefs) {
if (!si.document?.journal_entry_id) continue // unanchored: not underlag
if (si.registration_journal_entry_id) entriesWithDocs.add(si.registration_journal_entry_id)
if (si.payment_journal_entry_id) entriesWithDocs.add(si.payment_journal_entry_id)
}
const sipRefs = await fetchAllRows<{
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}>(({ from, to }) =>
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))'
)
.not('journal_entry_id', 'is', null)
.order('id')
.range(from, to) as unknown as PromiseLike<{
data: {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[] | null
error: { message: string } | null
}>
)
for (const sip of sipRefs) {
if (sip.journal_entry_id && sip.supplier_invoice?.document?.journal_entry_id) {
entriesWithDocs.add(sip.journal_entry_id)
}
}
// Entries the user has explicitly flagged as "no underlag required" (bank
// fees, interest, internal transfers, salary, tax payments). Treated as
// satisfied so we don't nag the user about them.
const { data: exempted } = await supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
const exemptedEntries = new Set(
(exempted || []).map((e) => e.journal_entry_id)
const exempted = await fetchAllRows<{ journal_entry_id: string }>(({ from, to }) =>
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
.order('journal_entry_id')
.range(from, to)
)
const exemptedEntries = new Set(exempted.map((e) => e.journal_entry_id))
// Group missing counts by user
const userMissingCounts = new Map<string, number>()
for (const entry of entries) {
@@ -90,7 +90,7 @@ export default function StripeSettingsPanel() {
}
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
}
router.replace('/settings/payments')
router.replace('/import?mode=stripe')
})
return () => { cancelled = true }
}, [searchParams, router, toast, t])
+1 -1
View File
@@ -53,7 +53,7 @@ export const stripeExtension: Extension = {
settingsPanel: {
label: 'Betalningar (Stripe)',
path: '/settings/payments',
path: '/import?mode=stripe',
},
// Core-callable services, resolved via the extension registry (core never
@@ -52,7 +52,8 @@ const mockDoc: TICCompanyDocument = {
{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' },
],
legalEntityType: 'AB',
registrationDate: 0,
// 2026-02-02 in Unix seconds (TIC's native unit; the route converts to ms)
registrationDate: Math.floor(Date.UTC(2026, 1, 2) / 1000),
mostRecentRegisteredAddress: {
streetAddress: 'Storgatan 1',
postalCode: '111 22',
@@ -114,6 +115,28 @@ describe('TIC lookup route', () => {
expect(data.email).toBe('info@test.se')
expect(data.phone).toBe('08-1234567')
expect(data.fiscalYear).toEqual({ startMonthDay: '01-01', endMonthDay: '12-31' })
expect(data.registrationDate).toBe(Date.UTC(2026, 1, 2))
})
it('converts registrationDate from Unix seconds to a millisecond epoch', async () => {
mockSearch.mockResolvedValue(mockDoc)
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
// Regression: fed raw seconds into `new Date()`, a 2026 registration
// rendered as 1970-01-21 in onboarding's fiscal-year step.
expect(new Date(data.registrationDate).toISOString().slice(0, 10)).toBe('2026-02-02')
})
it('returns registrationDate null when the doc lacks one', async () => {
mockSearch.mockResolvedValue({
...mockDoc,
registrationDate: undefined as unknown as number,
})
const res = await lookupHandler(makeRequest('556036-0793'))
const { data } = await res.json()
expect(data.registrationDate).toBeNull()
})
it('does NOT fan out to Phase 2 endpoints', async () => {
@@ -70,7 +70,8 @@ const mockDoc: TICCompanyDocument = {
{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' },
],
legalEntityType: 'AB',
registrationDate: 946684800000,
// 2000-01-01 in Unix seconds (TIC's native unit; the route converts to ms)
registrationDate: 946684800,
mostRecentPurpose: 'Software development',
mostRecentRegisteredAddress: {
streetAddress: 'Storgatan 1',
@@ -231,6 +232,8 @@ describe('TIC profile route', () => {
expect(data.orgNumber).toBe('5560360793')
expect(data.companyName).toBe('Test AB')
expect(data.legalEntityType).toBe('AB')
// Converted from the doc's Unix seconds to a millisecond epoch.
expect(data.registrationDate).toBe(946684800000)
expect(data.activityStatus).toBe('isActive')
expect(data.purpose).toBe('Software development')
expect(data.address).toEqual({
+12 -2
View File
@@ -226,6 +226,16 @@ function deriveFiscalYearMonthDay(
return { startMonthDay, endMonthDay }
}
// The search doc's registrationDate is a Unix timestamp in seconds (same
// unit as periodStart/periodEnd above), but the app-facing contract
// (CompanyLookupResult / TICCompanyProfile) is a millisecond epoch:
// consumers feed it straight into `new Date()`. Skipping this conversion
// is how 2026 registrations rendered as "21 jan 1970" in onboarding.
function registrationDateToMs(unixSeconds: number | null | undefined): number | null {
if (unixSeconds == null || !Number.isFinite(unixSeconds)) return null
return unixSeconds * 1000
}
function handleTicError(
error: unknown,
log: { error: (msg: string, meta?: unknown) => void } | Console,
@@ -398,7 +408,7 @@ export const ticExtension: Extension = {
sniCodes,
fiscalYear,
legalEntityType: doc.legalEntityType ?? null,
registrationDate: doc.registrationDate ?? null,
registrationDate: registrationDateToMs(doc.registrationDate),
}
return NextResponse.json({ data: result })
@@ -719,7 +729,7 @@ export const ticExtension: Extension = {
orgNumber: doc.registrationNumber,
companyName,
legalEntityType: doc.legalEntityType,
registrationDate: doc.registrationDate,
registrationDate: registrationDateToMs(doc.registrationDate) ?? 0,
activityStatus: isCeasedProfile ? 'ceased' : (doc.activityStatus ?? null),
purpose,
address: doc.mostRecentRegisteredAddress
@@ -1,12 +1,31 @@
import { describe, it, expect } from 'vitest'
import {
buildAnlaggningstillgangarNote,
computeRollforwardTotals,
_buildRollforwardForTests,
type AnlaggningAsset,
} from '../anlaggningstillgangar-note'
const PERIOD_START = '2025-01-01'
const PERIOD_END = '2025-12-31'
// Depreciation figures are resolved upstream (asset-note-figures.ts) from
// posted schedules; this suite supplies them and pins the aggregation and
// formatting only. The exact-amount assertions replace the old approximate
// bands: the builder must pass figures through verbatim.
function makeAsset(overrides: Partial<AnlaggningAsset> = {}): AnlaggningAsset {
return {
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
figures: { ibAck: 0, aretsAvskrivning: 0, avgaendeAck: 0 },
...overrides,
}
}
describe('buildAnlaggningstillgangarNote: roll-forward', () => {
it('returns null when no assets fall in the period', () => {
expect(
@@ -22,14 +41,12 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
it('skips assets disposed before the period', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
makeAsset({
acquisition_date: '2020-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: '2024-12-31',
},
figures: { ibAck: 100_000, aretsAvskrivning: 0, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -37,17 +54,12 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(rows).toEqual([])
})
it('records IB anskaffningsvärde for assets acquired before the period', () => {
it('records IB anskaffningsvärde and booked depreciation for assets acquired before the period', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
makeAsset({
figures: { ibAck: 12_000, aretsAvskrivning: 12_000, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -57,25 +69,19 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(r.ibAnskaffning).toBe(60_000)
expect(r.tillkommande).toBe(0)
expect(r.ubAnskaffning).toBe(60_000)
// 1 year of depreciation on the books at IB (Jan 1 2024 → Dec 31 2024)
expect(r.ibAck).toBeGreaterThan(11_500)
expect(r.ibAck).toBeLessThan(12_500)
// Year's depreciation ≈ 12,000 (60,000 / 5)
expect(r.aretsAvskrivning).toBeGreaterThan(11_500)
expect(r.aretsAvskrivning).toBeLessThan(12_500)
expect(r.ibAck).toBe(12_000)
expect(r.aretsAvskrivning).toBe(12_000)
expect(r.ubAck).toBe(24_000)
expect(r.ubRedovisat).toBe(36_000)
})
it('records tillkommande for assets acquired during the period', () => {
it('records tillkommande for assets acquired during the period without any IB ack', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
makeAsset({
acquisition_date: '2025-07-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
figures: { ibAck: 999, aretsAvskrivning: 6_049, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -84,23 +90,20 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
const r = rows[0]
expect(r.ibAnskaffning).toBe(0)
expect(r.tillkommande).toBe(60_000)
// ibAck only accrues for assets acquired before the period, even if the
// upstream figure carried a nonzero value.
expect(r.ibAck).toBe(0)
// ~6 months depreciation ≈ 6,000
expect(r.aretsAvskrivning).toBeGreaterThan(5_500)
expect(r.aretsAvskrivning).toBeLessThan(6_500)
expect(r.aretsAvskrivning).toBe(6_049)
})
it('records avgående for assets disposed during the period', () => {
it('records avgående and the reversed accumulated depreciation for in-period disposals', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
makeAsset({
acquisition_date: '2023-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: '2025-06-30',
},
figures: { ibAck: 24_000, aretsAvskrivning: 5_951, avgaendeAck: 29_951 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -110,31 +113,22 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(r.ibAnskaffning).toBe(60_000)
expect(r.avgaende).toBe(60_000)
expect(r.ubAnskaffning).toBe(0)
// Disposed asset: at IB it had 2 years of depreciation ≈ 24,000;
// at disposal it had ~2.5 years ≈ 30,000.
expect(r.avgaendeAck).toBeGreaterThan(29_000)
expect(r.avgaendeAck).toBeLessThan(31_000)
expect(r.avgaendeAck).toBe(29_951)
expect(r.ubAck).toBe(0)
expect(r.ubRedovisat).toBe(0)
})
it('groups multiple assets in the same category', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
{
category: 'equipment',
makeAsset({
figures: { ibAck: 12_000, aretsAvskrivning: 12_000, avgaendeAck: 0 },
}),
makeAsset({
acquisition_date: '2025-01-01',
acquisition_cost: 40_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
figures: { ibAck: 0, aretsAvskrivning: 8_000, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -144,27 +138,22 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(r.ibAnskaffning).toBe(60_000)
expect(r.tillkommande).toBe(40_000)
expect(r.ubAnskaffning).toBe(100_000)
expect(r.aretsAvskrivning).toBe(20_000)
expect(r.ubAck).toBe(32_000)
})
it('separates categories', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
{
makeAsset({
figures: { ibAck: 12_000, aretsAvskrivning: 12_000, avgaendeAck: 0 },
}),
makeAsset({
category: 'computer',
acquisition_date: '2024-01-01',
acquisition_cost: 20_000,
salvage_value: 0,
useful_life_months: 36,
disposed_at: null,
},
figures: { ibAck: 6_667, aretsAvskrivning: 6_667, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -178,14 +167,9 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
const note = buildAnlaggningstillgangarNote({
noteNumber: 5,
assets: [
{
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
makeAsset({
figures: { ibAck: 12_000, aretsAvskrivning: 12_000, avgaendeAck: 0 },
}),
],
periodStart: PERIOD_START,
periodEnd: PERIOD_END,
@@ -197,40 +181,37 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(note!.body).toContain('Ingående anskaffningsvärde')
expect(note!.body).toContain('Utgående anskaffningsvärde')
expect(note!.body).toContain('Ingående ackumulerade avskrivningar')
expect(note!.body).toContain('Utgående redovisat värde')
// sv-SE formatting uses a non-breaking thousands separator: build the
// expected strings with the same formatter instead of literal spaces.
const sv = (n: number) => n.toLocaleString('sv-SE')
expect(note!.body).toContain(`Årets avskrivningar: -${sv(12_000)} kr`)
expect(note!.body).toContain(`Utgående redovisat värde: ${sv(36_000)} kr`)
})
it('respects salvage_value when computing depreciation', () => {
it('passes upstream figures through verbatim without recomputing', () => {
// Deliberately "impossible" figures for the asset's schedule: the
// builder must not derive anything from cost/life/dates on its own.
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 60_000,
salvage_value: 10_000,
useful_life_months: 60,
disposed_at: null,
},
makeAsset({
figures: { ibAck: 1_234.56, aretsAvskrivning: 7_890.12, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
)
// Depreciable base 50,000 over 5 years → 10,000/yr (not 12,000)
expect(rows[0].aretsAvskrivning).toBeGreaterThan(9_500)
expect(rows[0].aretsAvskrivning).toBeLessThan(10_500)
expect(rows[0].ibAck).toBe(1_234.56)
expect(rows[0].aretsAvskrivning).toBe(7_890.12)
expect(rows[0].ubAck).toBe(9_124.68)
})
it('caps accumulated depreciation at depreciable base after useful life', () => {
it('caps at the depreciable base only via upstream figures (fully depreciated asset)', () => {
const rows = _buildRollforwardForTests(
[
{
category: 'equipment',
makeAsset({
acquisition_date: '2010-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60,
disposed_at: null,
},
figures: { ibAck: 60_000, aretsAvskrivning: 0, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
@@ -241,3 +222,25 @@ describe('buildAnlaggningstillgangarNote: roll-forward', () => {
expect(rows[0].ubRedovisat).toBe(0)
})
})
describe('computeRollforwardTotals', () => {
it('sums closing book value and accumulated depreciation across categories', () => {
const totals = computeRollforwardTotals(
[
makeAsset({
figures: { ibAck: 12_000, aretsAvskrivning: 12_000, avgaendeAck: 0 },
}),
makeAsset({
category: 'computer',
acquisition_cost: 20_000,
figures: { ibAck: 6_667, aretsAvskrivning: 6_667, avgaendeAck: 0 },
}),
],
PERIOD_START,
PERIOD_END,
)
// equipment: 60,000 - 24,000 = 36,000; computer: 20,000 - 13,334 = 6,666
expect(totals.ubAck).toBe(37_334)
expect(totals.ubRedovisat).toBe(42_666)
})
})
@@ -380,6 +380,17 @@ describe('buildArsredovisningData: K3', () => {
expect(data.warnings.find((w) => w.startsWith('Aktiekapitalnoten saknas'))).toBeDefined()
})
it('never queries depreciation_schedules when the asset register is empty', async () => {
// buildRollforwardAssets skips the posted-schedules fetch entirely for
// an empty register: pinning this keeps the makeSupabase mock (which has
// no depreciation_schedules branch) honest.
const supabase = makeSupabase({ accountingFramework: 'k3' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
const tables = supabase.from.mock.calls.map((call) => call[0])
expect(tables).not.toContain('depreciation_schedules')
})
it('DROPS the old "K3 noter need manual augmentation" warning text', async () => {
const supabase = makeSupabase({ accountingFramework: 'k3' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
@@ -0,0 +1,232 @@
import { describe, it, expect } from 'vitest'
import {
computeAssetNoteFigures,
type PostedScheduleRow,
type PeriodLike,
} from '../asset-note-figures'
import type { Asset } from '@/types'
const FP2023: PeriodLike = { id: 'fp2023', period_start: '2023-01-01', period_end: '2023-12-31' }
const FP2024: PeriodLike = { id: 'fp2024', period_start: '2024-01-01', period_end: '2024-12-31' }
const FP2025: PeriodLike = { id: 'fp2025', period_start: '2025-01-01', period_end: '2025-12-31' }
const ALL_PERIODS = [FP2023, FP2024, FP2025]
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Testinventarie',
category: 'equipment',
acquisition_date: '2024-01-01',
acquisition_cost: 146_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
disposed_proceeds_vat: 0,
disposed_vat_treatment: null,
jamkning_amount: 0,
jamkning_remaining_months: null,
jamkning_total_months: null,
jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
} as Asset
}
function posted(
assetId: string,
periodId: string,
amount: number | string,
): PostedScheduleRow {
return {
asset_id: assetId,
fiscal_period_id: periodId,
planned_depreciation: amount,
journal_entry_id: `je-${assetId}-${periodId}`,
}
}
function figuresFor(
asset: Asset,
schedules: PostedScheduleRow[],
periods: PeriodLike[] = ALL_PERIODS,
currentPeriodId = 'fp2025',
) {
const map = computeAssetNoteFigures({
assets: [asset],
postedSchedules: schedules,
fiscalPeriods: periods,
currentPeriodId,
})
return map.get(asset.id)
}
describe('computeAssetNoteFigures', () => {
it('uses posted schedule amounts verbatim (regression: 20 kr note drift)', () => {
// 146,000 kr over 60 months books round(146000 * 12/60) = 29,200/year.
// The old theoretical formula (base * 365 / (60 * 30.4375)) gave 29,180:
// the exact 20 kr inconsistency reported against the ledger-driven RR/BR.
const asset = makeAsset()
const f = figuresFor(asset, [
posted('asset-1', 'fp2024', 29_200),
posted('asset-1', 'fp2025', 29_200),
])
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('falls back to the booking engine for the current year when nothing is posted', () => {
const asset = makeAsset()
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 29_200)])
// Engine full-year linear: round(146000 * 12/60) = 29,200, not 29,180.
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('iterates synthetic prior years through the engine for pre-onboarding assets', () => {
// Acquired 2023-01-01 but only the current fiscal period exists in the
// DB and nothing was ever posted: two synthetic 12-month windows.
const asset = makeAsset({ acquisition_date: '2023-01-01' })
const f = figuresFor(asset, [], [FP2025])
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('caps the engine-iterated fallback at the depreciable base after useful life', () => {
const asset = makeAsset({ acquisition_date: '2010-01-01' })
const f = figuresFor(asset, [], [FP2025])
expect(f).toEqual({ ibAck: 146_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('applies declining_balance_30 on the posted book value', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'declining_balance_30',
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 30_000)])
// Book value 70,000 * 30% = 21,000
expect(f).toEqual({ ibAck: 30_000, aretsAvskrivning: 21_000, avgaendeAck: 0 })
})
it('floors restvardesavskrivning_25 at the restvarde target', () => {
const asset = makeAsset({
acquisition_date: '2024-01-01',
acquisition_cost: 100_000,
depreciation_method: 'restvardesavskrivning_25',
restvarde_target: 50_000,
})
const f = figuresFor(asset, [posted('asset-1', 'fp2024', 50_000)])
expect(f).toEqual({ ibAck: 50_000, aretsAvskrivning: 0, avgaendeAck: 0 })
})
it('sums K3 component depreciation via the engine fallback', () => {
const asset = makeAsset({
acquisition_date: '2025-01-01',
acquisition_cost: 100_000,
k3_components: [
{ name: 'Stomme', cost: 80_000, useful_life_months: 120 },
{ name: 'Tak', cost: 20_000, useful_life_months: 60, salvage_value: 0 },
],
})
const f = figuresFor(asset, [])
// round(80000 * 12/120) + round(20000 * 12/60) = 8,000 + 4,000
expect(f).toEqual({ ibAck: 0, aretsAvskrivning: 12_000, avgaendeAck: 0 })
})
it('ignores draft schedules (journal_entry_id null) everywhere', () => {
const asset = makeAsset()
const drafts: PostedScheduleRow[] = [
{ asset_id: 'asset-1', fiscal_period_id: 'fp2024', planned_depreciation: 999, journal_entry_id: null },
{ asset_id: 'asset-1', fiscal_period_id: 'fp2025', planned_depreciation: 999, journal_entry_id: null },
]
const f = figuresFor(asset, drafts)
// Both fall back to the engine: 2024 via prior-year iteration, 2025 live.
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('mirrors disposeAsset for in-period disposals: all posted rows reversed, nothing new charged', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [
posted('asset-1', 'fp2023', 29_200),
posted('asset-1', 'fp2024', 29_200),
])
// disposeAsset reversed sumPostedDepreciation() = 58,400; no current-year
// charge was ever booked, so the note must not invent one.
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 0, avgaendeAck: 58_400 })
})
it('includes a posted current-year row in the disposal reversal so the asset nets to zero', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [
posted('asset-1', 'fp2023', 29_200),
posted('asset-1', 'fp2024', 29_200),
posted('asset-1', 'fp2025', 29_200),
])
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 29_200, avgaendeAck: 87_600 })
expect(f!.ibAck + f!.aretsAvskrivning - f!.avgaendeAck).toBe(0)
})
it('previews engine amounts for disposals with no posted schedules at all', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
})
const f = figuresFor(asset, [])
// Fallback ibAck = 2 full engine years = 58,400. Current year clamps at
// the disposal date: round(29200 * 181/365) = 14,480. The reversal takes
// the asset's accumulated depreciation back to zero.
expect(f).toEqual({ ibAck: 58_400, aretsAvskrivning: 14_480, avgaendeAck: 72_880 })
})
it('coerces NUMERIC string amounts from PostgREST', () => {
const asset = makeAsset()
const f = figuresFor(asset, [
posted('asset-1', 'fp2024', '29200.00'),
posted('asset-1', 'fp2025', '29200.00'),
])
expect(f).toEqual({ ibAck: 29_200, aretsAvskrivning: 29_200, avgaendeAck: 0 })
})
it('omits assets disposed before the period', () => {
const asset = makeAsset({
acquisition_date: '2023-01-01',
disposed_at: '2024-12-31',
disposed_proceeds: 0,
})
const map = computeAssetNoteFigures({
assets: [asset],
postedSchedules: [],
fiscalPeriods: ALL_PERIODS,
currentPeriodId: 'fp2025',
})
expect(map.has('asset-1')).toBe(false)
})
it('throws when the current period is missing from the period list', () => {
expect(() =>
computeAssetNoteFigures({
assets: [makeAsset()],
postedSchedules: [],
fiscalPeriods: [FP2024],
currentPeriodId: 'fp2025',
}),
).toThrow('Current fiscal period not found')
})
})
@@ -14,14 +14,16 @@
*
* Utgående redovisat värde = utgående anskaffningsvärde − utgående ack. avskrivningar
*
* Driven entirely off the assets table. Accumulated depreciation is
* computed from the linear schedule (acquisition_date, useful_life_months,
* salvage_value) at the relevant as-of date: we do not depend on
* journal-derived avskrivningskonton because not all companies post
* monthly avskrivningar.
* Anskaffningsvärden come from the asset register. Depreciation figures are
* resolved UPSTREAM (asset-note-figures.ts) from posted depreciation
* schedules, with the booking engine as fallback, so the note ties to the
* balansräkning and resultaträkning instead of re-deriving a theoretical
* schedule that can drift from what was actually booked. This module only
* aggregates per category and formats.
*/
import type { NoteEntry } from './types'
import type { AssetDepreciationFigures } from './asset-note-figures'
export interface AnlaggningAsset {
category: string
@@ -30,6 +32,7 @@ export interface AnlaggningAsset {
salvage_value: number
useful_life_months: number
disposed_at: string | null
figures: AssetDepreciationFigures
}
interface CategoryRollforward {
@@ -56,44 +59,11 @@ const CATEGORY_LABELS: Record<string, string> = {
other_tangible: 'Övriga materiella anläggningstillgångar',
}
function daysBetween(startIso: string, endIso: string): number {
const start = new Date(`${startIso}T00:00:00Z`)
const end = new Date(`${endIso}T00:00:00Z`)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0
if (end < start) return 0
return Math.floor((end.getTime() - start.getTime()) / 86400000)
}
/**
* Linear depreciation accumulated between acquisition_date and asOfIso.
* Caps at (cost − salvage) once useful life elapses. Day-based pro-rata
* matching how computeLinearDepreciation pro-rates the first/last year.
*/
function accumulatedDepreciation(
asset: AnlaggningAsset,
asOfIso: string,
): number {
if (asOfIso < asset.acquisition_date) return 0
const lifeDays = asset.useful_life_months * (365.25 / 12)
const elapsedDays = Math.min(lifeDays, daysBetween(asset.acquisition_date, asOfIso))
if (lifeDays === 0) return 0
const depreciable = asset.acquisition_cost - asset.salvage_value
return Math.round((depreciable * elapsedDays) / lifeDays * 100) / 100
}
/** ISO date one day before a given ISO date. Used for "day before period start". */
function isoMinusOneDay(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() - 1)
return d.toISOString().slice(0, 10)
}
function buildRollforward(
assets: AnlaggningAsset[],
periodStart: string,
periodEnd: string,
): CategoryRollforward[] {
const dayBeforeStart = isoMinusOneDay(periodStart)
const byCategory = new Map<string, CategoryRollforward>()
const getRow = (category: string): CategoryRollforward => {
@@ -136,30 +106,17 @@ function buildRollforward(
if (acquiredBeforePeriod) {
// Was on the books at the start of the period
row.ibAnskaffning += asset.acquisition_cost
row.ibAck += accumulatedDepreciation(asset, dayBeforeStart)
row.ibAck += asset.figures.ibAck
}
if (acquiredDuringPeriod) {
row.tillkommande += asset.acquisition_cost
}
if (disposedDuringPeriod) {
row.avgaende += asset.acquisition_cost
row.avgaendeAck += accumulatedDepreciation(asset, asset.disposed_at!)
row.avgaendeAck += asset.figures.avgaendeAck
}
// Year's depreciation: from max(acquisition_date, period_start)
// to min(disposed_at ?? period_end, period_end). Computed as the
// delta in accumulated depreciation between those two dates.
const yearStart =
asset.acquisition_date > periodStart ? asset.acquisition_date : periodStart
const yearEnd =
asset.disposed_at != null && asset.disposed_at < periodEnd
? asset.disposed_at
: periodEnd
if (yearStart <= yearEnd) {
const startAck = accumulatedDepreciation(asset, isoMinusOneDay(yearStart))
const endAck = accumulatedDepreciation(asset, yearEnd)
row.aretsAvskrivning += Math.max(0, endAck - startAck)
}
row.aretsAvskrivning += asset.figures.aretsAvskrivning
}
// Close out totals + ordering
@@ -187,6 +144,23 @@ function buildRollforward(
return rows
}
/**
* Document-level totals for the roll-forward: used by build-data to
* cross-check the note's closing book value against the balansräkning.
*/
export function computeRollforwardTotals(
assets: AnlaggningAsset[],
periodStart: string,
periodEnd: string,
): { ubRedovisat: number; ubAck: number } {
const rows = buildRollforward(assets, periodStart, periodEnd)
return {
ubRedovisat:
Math.round(rows.reduce((sum, r) => sum + r.ubRedovisat, 0) * 100) / 100,
ubAck: Math.round(rows.reduce((sum, r) => sum + r.ubAck, 0) * 100) / 100,
}
}
const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE')
/**
@@ -0,0 +1,203 @@
/**
* Per-asset depreciation figures for the anläggningstillgångar roll-forward
* note (ÅRL 5:8 §), anchored to what was actually BOOKED.
*
* The note must reconcile with the balansräkning, which is ledger-driven.
* Posted `depreciation_schedules` rows are the source of truth: they carry
* the exact amounts the engine committed to the journal, and they are also
* exactly what `disposeAsset()` reverses via `sumPostedDepreciation()`.
*
* Fallbacks (only when nothing is posted for the relevant span) reuse the
* booking engine's `computeAnnualDepreciation` so the note previews the
* same amount that WOULD be booked: never a parallel formula. For years
* before the company's fiscal periods exist in the DB (pre-onboarding
* assets), synthetic 12-month windows are stepped back from the earliest
* known period start; irregular pre-onboarding years are approximated,
* which the balansräkning tie-out warning in build-data surfaces.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { computeAnnualDepreciation } from '@/lib/bokslut/assets/depreciation-engine'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Asset } from '@/types'
export interface AssetDepreciationFigures {
/** Accumulated depreciation on the books at period start. */
ibAck: number
/** Depreciation charged during the period (posted schedule, else engine). */
aretsAvskrivning: number
/** Accumulated depreciation reversed on in-period disposal. */
avgaendeAck: number
}
export interface PostedScheduleRow {
asset_id: string
fiscal_period_id: string
/** NUMERIC arrives as string from PostgREST. */
planned_depreciation: number | string
journal_entry_id: string | null
}
export interface PeriodLike {
id: string
period_start: string
period_end: string
}
const round2 = (n: number) => Math.round(n * 100) / 100
/** Posted (journal-linked) schedule rows for the whole company, paginated. */
export async function loadPostedSchedules(
supabase: SupabaseClient,
companyId: string,
): Promise<PostedScheduleRow[]> {
return fetchAllRows<PostedScheduleRow>(({ from, to }) =>
supabase
.from('depreciation_schedules')
.select('asset_id, fiscal_period_id, planned_depreciation, journal_entry_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.order('id')
.range(from, to),
)
}
function isoAddDays(iso: string, days: number): string {
const d = new Date(`${iso}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** Shift an ISO date by whole years, clamping Feb 29 to Feb 28. */
function isoShiftYears(iso: string, deltaYears: number): string {
const d = new Date(`${iso}T00:00:00Z`)
const year = d.getUTCFullYear() + deltaYears
const month = d.getUTCMonth()
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(year, month, Math.min(d.getUTCDate(), lastDay)))
.toISOString()
.slice(0, 10)
}
/**
* Accumulated depreciation at period start for an asset with NO posted
* prior schedules. Iterates prior years chronologically through the
* engine (declining methods compound, so order matters): real DB fiscal
* periods where they exist, synthetic 12-month windows stepped back from
* the earliest known period start for the pre-onboarding years before.
*/
function fallbackAccumulatedBefore(
asset: Asset,
currentPeriodStart: string,
fiscalPeriods: PeriodLike[],
): number {
const priorPeriods = fiscalPeriods
.filter((p) => p.period_start < currentPeriodStart)
.sort((a, b) => a.period_start.localeCompare(b.period_start))
const anchorStart =
priorPeriods.length > 0 ? priorPeriods[0].period_start : currentPeriodStart
const synthetic: { period_start: string; period_end: string }[] = []
let windowEnd = isoAddDays(anchorStart, -1)
let windowStart = isoShiftYears(anchorStart, -1)
while (windowEnd >= asset.acquisition_date) {
synthetic.unshift({ period_start: windowStart, period_end: windowEnd })
windowEnd = isoAddDays(windowStart, -1)
windowStart = isoShiftYears(windowStart, -1)
}
let accumulated = 0
for (const window of [...synthetic, ...priorPeriods]) {
if (window.period_end < asset.acquisition_date) continue
const { amount } = computeAnnualDepreciation(asset, window, accumulated)
accumulated = round2(accumulated + amount)
}
return accumulated
}
/**
* Resolve booked-anchored figures for every asset alive in the period.
* Assets disposed before the period are omitted (the note skips them).
*/
export function computeAssetNoteFigures(params: {
assets: Asset[]
postedSchedules: PostedScheduleRow[]
fiscalPeriods: PeriodLike[]
currentPeriodId: string
}): Map<string, AssetDepreciationFigures> {
const { assets, postedSchedules, fiscalPeriods, currentPeriodId } = params
const currentPeriod = fiscalPeriods.find((p) => p.id === currentPeriodId)
if (!currentPeriod) {
throw new Error('Current fiscal period not found in fiscal period list')
}
const periodStartById = new Map(fiscalPeriods.map((p) => [p.id, p.period_start]))
const byAsset = new Map<string, PostedScheduleRow[]>()
for (const row of postedSchedules) {
if (row.journal_entry_id == null) continue
const list = byAsset.get(row.asset_id)
if (list) list.push(row)
else byAsset.set(row.asset_id, [row])
}
const figures = new Map<string, AssetDepreciationFigures>()
for (const asset of assets) {
if (asset.disposed_at && asset.disposed_at < currentPeriod.period_start) continue
const rows = byAsset.get(asset.id) ?? []
const currentPosted = rows.find((r) => r.fiscal_period_id === currentPeriodId)
const priorPosted = rows.filter((r) => {
const start = periodStartById.get(r.fiscal_period_id)
return start != null && start < currentPeriod.period_start
})
let ibAck: number
if (priorPosted.length > 0) {
ibAck = round2(
priorPosted.reduce((sum, r) => sum + (Number(r.planned_depreciation) || 0), 0),
)
} else if (asset.acquisition_date >= currentPeriod.period_start) {
ibAck = 0
} else {
ibAck = fallbackAccumulatedBefore(asset, currentPeriod.period_start, fiscalPeriods)
}
const disposedDuringPeriod =
asset.disposed_at != null &&
asset.disposed_at >= currentPeriod.period_start &&
asset.disposed_at <= currentPeriod.period_end
let aretsAvskrivning: number
let avgaendeAck = 0
if (disposedDuringPeriod) {
const hasAnyPosted = rows.length > 0
if (hasAnyPosted) {
// Mirror sumPostedDepreciation()/disposeAsset(): the ledger reversed
// exactly the sum of all posted rows, and the current year's charge
// is whatever was posted for this period (often nothing).
aretsAvskrivning = currentPosted
? Number(currentPosted.planned_depreciation) || 0
: 0
avgaendeAck = round2(
rows.reduce((sum, r) => sum + (Number(r.planned_depreciation) || 0), 0),
)
} else {
// Nothing ever posted: preview what the engine would have booked so
// the disposed asset still nets out of the roll-forward.
aretsAvskrivning = computeAnnualDepreciation(asset, currentPeriod, ibAck).amount
avgaendeAck = round2(ibAck + aretsAvskrivning)
}
} else if (currentPosted) {
aretsAvskrivning = Number(currentPosted.planned_depreciation) || 0
} else {
aretsAvskrivning = computeAnnualDepreciation(asset, currentPeriod, ibAck).amount
}
figures.set(asset.id, {
ibAck,
aretsAvskrivning: round2(aretsAvskrivning),
avgaendeAck,
})
}
return figures
}
+110 -21
View File
@@ -19,7 +19,12 @@ import {
buildMateriellaAnlaggningsNot,
buildUppskjutenSkattNot,
} from './k3-noter-builder'
import { buildAnlaggningstillgangarNote } from './anlaggningstillgangar-note'
import {
buildAnlaggningstillgangarNote,
computeRollforwardTotals,
type AnlaggningAsset,
} from './anlaggningstillgangar-note'
import { computeAssetNoteFigures, loadPostedSchedules } from './asset-note-figures'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import type {
ArsredovisningData,
@@ -250,6 +255,8 @@ export async function buildArsredovisningData(
period.period_end,
narrative,
tbFull.rows,
fiscalPeriodId,
(periodList ?? []) as PeriodRow[],
),
generateKassaflodesanalys(supabase, companyId, fiscalPeriodId).then(
(cashFlow) => ({ ok: true as const, cashFlow }),
@@ -292,6 +299,9 @@ export async function buildArsredovisningData(
period.period_start,
period.period_end,
narrative,
tbFull.rows,
fiscalPeriodId,
(periodList ?? []) as PeriodRow[],
)
noter = k2Noter.notes
noterWarnings = k2Noter.warnings
@@ -519,6 +529,60 @@ function buildEquityChanges(mapping: K2MappingResult): EgenKapitalRow[] {
return rows
}
/**
* Map register assets to the roll-forward note input, resolving per-asset
* depreciation figures from posted schedules (engine fallback) so the note
* ties to the ledger. Shared by the K2 and K3 note builders. Skips the
* schedules query entirely when the register is empty.
*/
async function buildRollforwardAssets(
supabase: SupabaseClient,
companyId: string,
assets: Asset[],
allPeriods: PeriodRow[],
fiscalPeriodId: string,
): Promise<AnlaggningAsset[]> {
if (assets.length === 0) return []
const schedules = await loadPostedSchedules(supabase, companyId)
const figures = computeAssetNoteFigures({
assets,
postedSchedules: schedules,
fiscalPeriods: allPeriods,
currentPeriodId: fiscalPeriodId,
})
return assets.map((a) => ({
category: a.category,
acquisition_date: a.acquisition_date,
acquisition_cost: a.acquisition_cost,
salvage_value: a.salvage_value,
useful_life_months: a.useful_life_months,
disposed_at: a.disposed_at,
figures: figures.get(a.id) ?? { ibAck: 0, aretsAvskrivning: 0, avgaendeAck: 0 },
}))
}
/**
* Cross-check the roll-forward note's closing book value against the
* balansräkning (full TB net of accounts 1000-1299: immateriella +
* materiella anläggningstillgångar; 13xx financial assets are outside the
* note). Returns a user-facing warning when they diverge by more than 1 kr,
* which is the exact inconsistency ÅRL 5:8 § forbids in a filed document.
*/
function rollforwardTieOutWarning(
rollforwardAssets: AnlaggningAsset[],
tbFullRows: TrialBalanceRow[],
periodStart: string,
periodEnd: string,
): string | null {
const totals = computeRollforwardTotals(rollforwardAssets, periodStart, periodEnd)
const tbNet = tbFullRows
.filter((r) => r.account_number >= '1000' && r.account_number < '1300')
.reduce((sum, r) => sum + (r.closing_debit || 0) - (r.closing_credit || 0), 0)
if (Math.abs(totals.ubRedovisat - tbNet) <= 1) return null
const fmtKr = (n: number) => Math.round(n).toLocaleString('sv-SE')
return `Anläggningsnotens utgående redovisade värde (${fmtKr(totals.ubRedovisat)} kr) stämmer inte med balansräkningens bokförda värde för konto 1000-1299 (${fmtKr(tbNet)} kr). Kontrollera att anläggningsregistret är komplett och att årets avskrivningar är bokförda.`
}
async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
@@ -526,6 +590,9 @@ async function buildK2Noter(
periodStart: string,
periodEnd: string,
narrative: NarrativeRow | null,
tbFullRows: TrialBalanceRow[],
fiscalPeriodId: string,
allPeriods: PeriodRow[],
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
@@ -639,21 +706,31 @@ async function buildK2Noter(
// Anläggningstillgångar roll-forward (ÅRL 5:8 §). Per-category IB →
// tillkommande → avgående → UB anskaffningsvärde, same for ackumulerade
// avskrivningar, ending in utgående redovisat värde. Hard ÅR requirement
// for any company with assets on the books.
// for any company with assets on the books. Depreciation figures come
// from posted schedules so the note ties to the balansräkning.
const rollforwardAssets = await buildRollforwardAssets(
supabase,
companyId,
assets,
allPeriods,
fiscalPeriodId,
)
const rollforwardNote = buildAnlaggningstillgangarNote({
noteNumber: notes.length + 1,
assets: assets.map((a) => ({
category: a.category,
acquisition_date: a.acquisition_date,
acquisition_cost: a.acquisition_cost,
salvage_value: a.salvage_value,
useful_life_months: a.useful_life_months,
disposed_at: a.disposed_at,
})),
assets: rollforwardAssets,
periodStart,
periodEnd,
})
if (rollforwardNote) notes.push(rollforwardNote)
if (rollforwardNote) {
notes.push(rollforwardNote)
const tieOut = rollforwardTieOutWarning(
rollforwardAssets,
tbFullRows,
periodStart,
periodEnd,
)
if (tieOut) warnings.push(tieOut)
}
// Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. We fetch the
// full employment-window data because the column 'is_active' doesn't exist
@@ -758,6 +835,8 @@ async function buildK3Noter(
periodEndIso: string,
narrative: NarrativeRow | null,
tbFullRows: TrialBalanceRow[],
fiscalPeriodId: string,
allPeriods: PeriodRow[],
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
@@ -886,21 +965,31 @@ async function buildK3Noter(
// 3b. Anläggningstillgångar roll-forward (ÅRL 5:8 §). Required even under
// K3: K3 ch.17 layers component depreciation on top, but the basic
// per-category roll-forward of anskaffningsvärde + ackumulerade
// avskrivningar is the statutory baseline.
// avskrivningar is the statutory baseline. Depreciation figures come
// from posted schedules so the note ties to the balansräkning.
const rollforwardAssets = await buildRollforwardAssets(
supabase,
companyId,
assets,
allPeriods,
fiscalPeriodId,
)
const rollforwardNote = buildAnlaggningstillgangarNote({
noteNumber: notes.length + 1,
assets: assets.map((a) => ({
category: a.category,
acquisition_date: a.acquisition_date,
acquisition_cost: a.acquisition_cost,
salvage_value: a.salvage_value,
useful_life_months: a.useful_life_months,
disposed_at: a.disposed_at,
})),
assets: rollforwardAssets,
periodStart: periodStartIso,
periodEnd: periodEndIso,
})
if (rollforwardNote) notes.push(rollforwardNote)
if (rollforwardNote) {
notes.push(rollforwardNote)
const tieOut = rollforwardTieOutWarning(
rollforwardAssets,
tbFullRows,
periodStartIso,
periodEndIso,
)
if (tieOut) warnings.push(tieOut)
}
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
// movement, and closing balance of uppskjuten skatteskuld. We derive
+3 -1
View File
@@ -45,7 +45,9 @@ export interface CompanyLookupResult {
*/
legalEntityType?: string | null
/**
* Company registration date as a millisecond epoch (TIC's native format).
* Company registration date as a millisecond epoch. TIC's search API
* natively returns Unix seconds; the TIC extension converts to ms at the
* boundary so consumers can feed this straight into `new Date()`.
* Onboarding Step 3 uses this to infer `is_first_fiscal_year`: when the
* company was registered less than 12 months ago, we pre-check the
* first-year toggle and seed `first_year_start` from the registration
+15 -11
View File
@@ -5,7 +5,7 @@ export const CONNECT_CLAUDE_MD = `# Connect with Claude
Accounted ships an [MCP](https://modelcontextprotocol.io) server that exposes the full bookkeeping engine (90+ tools) to any MCP client. The endpoint is:
\`\`\`
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted
\`\`\`
There are two ways to connect, depending on your client. Both reach the same tools and the same approval model: read tools answer immediately, write tools (categorise, mark paid, create voucher, year-end) **stage a pending operation** that you confirm in chat or in the **/pending** web UI before anything is booked.
@@ -17,16 +17,16 @@ Best for most users. No API key to manage: you authorise Accounted the same way
1. In **claude.ai** (Settings → Connectors) or **Claude Desktop** (Settings → Connectors → Add custom connector), choose **Add custom connector**.
2. Paste the connector URL:
\`\`\`
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp?client=claude-connector
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted&client=claude-connector
\`\`\`
_(The "?client=claude-connector" suffix is telemetry-only: it lets Accounted see you connected via claude.ai/Desktop. It changes nothing about behaviour or scopes; drop the query string if you prefer.)_
_The "client=claude-connector" parameter is telemetry-only. Keep "tool_namespace=accounted": it selects the Accounted tool names._
3. Claude opens the Accounted OAuth 2.1 consent screen. Sign in and pick the company you want Claude to act on.
4. On the consent screen you grant **read-only scopes by default** (list invoices, read reports, compute VAT). Write scopes (create invoice, categorise, book vouchers, run year-end) are **listed separately and must be ticked explicitly**: leave them unchecked for a read-only review session.
5. Approve. Claude now lists the Accounted tools and you can start asking questions.
Because the consent is per-company and scoped, you can connect a read-only key for a reviewer and a separate write-enabled connection for day-to-day bookkeeping.
## Path B: \`npx gnubok-mcp\` with an API key (stdio bridge)
## Path B: \`npx accounted-mcp\` with an API key (stdio bridge)
Best for Claude Desktop on a machine where you'd rather use a long-lived API key than the OAuth flow, or for scripting.
@@ -35,12 +35,12 @@ Best for Claude Desktop on a machine where you'd rather use a long-lived API key
\`\`\`json
{
"mcpServers": {
"gnubok": {
"accounted": {
"command": "npx",
"args": ["gnubok-mcp"],
"args": ["-y", "accounted-mcp"],
"env": {
"GNUBOK_API_KEY": "gnubok_sk_test_...",
"GNUBOK_CLIENT": "claude-desktop"
"ACCOUNTED_API_KEY": "gnubok_sk_test_...",
"ACCOUNTED_CLIENT": "claude-desktop"
}
}
}
@@ -50,16 +50,20 @@ Best for Claude Desktop on a machine where you'd rather use a long-lived API key
The key's scopes gate exactly which tools are callable: a key without write scopes can read reports and ledgers but cannot stage a booking.
The API-key value still begins with \`gnubok_sk_\`. That is a stable credential
format, not the MCP integration name. Existing \`gnubok-mcp\` configurations
continue to work without changes.
## Try these prompts
All three run against the deterministic sandbox seed (use a \`gnubok_sk_test_*\` key or pick the sandbox company on the OAuth consent screen). They exercise the read path end-to-end without booking anything.
1. **"Show my uncategorized bank transactions and suggest categories."**
Claude calls \`gnubok_list_uncategorized_transactions\` then \`gnubok_suggest_categories\` and walks you through the proposals. Approving one stages a \`gnubok_categorize_transaction\` pending operation: nothing is booked until you confirm.
Claude calls \`accounted_list_uncategorized_transactions\` then \`accounted_suggest_categories\` and walks you through the proposals. Approving one stages an \`accounted_categorize_transaction\` pending operation: nothing is booked until you confirm.
2. **"Which invoices are overdue?"**
Claude calls \`gnubok_get_ar_ledger\` (kundreskontra) and lists outstanding customer invoices with aging.
Claude calls \`accounted_get_ar_ledger\` (kundreskontra) and lists outstanding customer invoices with aging.
3. **"Compute my VAT report for this quarter and tell me if I can close it."**
Claude calls \`gnubok_get_vat_report\` for the momsdeklaration rutor, then \`gnubok_vat_close_check\` to scan for blockers (uncategorised rows, unapproved supplier invoices, missing receipts on expenses ≥ 4 000 kr: the tool's high-value heuristic; BFL requires underlag for every affärshändelse regardless of amount) and reports \`ready_to_close\`.
Claude calls \`accounted_get_vat_report\` for the momsdeklaration rutor, then \`accounted_vat_close_check\` to scan for blockers (uncategorised rows, unapproved supplier invoices, missing receipts on expenses ≥ 4 000 kr: the tool's high-value heuristic; BFL requires underlag for every affärshändelse regardless of amount) and reports \`ready_to_close\`.
## 10-minute reviewer test
@@ -111,7 +111,7 @@ Response (cursor-paginated, newest-imported first — ordered by \`created_at\`
## 4. Decide the category
There is no category-suggestion endpoint in the v1 REST API. Ranked suggestions (from the description, counterparty history, and your booking-template library) are surfaced by the dashboard and by the MCP tool \`gnubok_suggest_categories\` — not over REST.
There is no category-suggestion endpoint in the v1 REST API. Ranked suggestions (from the description, counterparty history, and your booking-template library) are surfaced by the dashboard and by the MCP tool \`accounted_suggest_categories\`: not over REST.
In a REST integration you supply the category yourself: choose a \`category\`, or pass an explicit \`account_override\` / \`template_id\` / \`counterparty_template_id\`, based on your own mapping logic. When categorising interactively, use the dry-run in the next step to preview the resolved verifikation before you commit.
@@ -65,8 +65,8 @@ The check returns a \`findings\` array; each unexplained gap is a \`blocker\` (c
For aktiebolag, BFL 7 kap requires every verifikation to have its underlag (receipt, faktura, kontrakt) attached. The v1 \`compliance/check\` endpoint does **not** gate on documents — its only supported types are \`year_end_readiness\` and \`voucher_gaps\` — so surface missing underlag through the MCP tools instead:
\`\`\`
gnubok_list_verifikat_without_documents
gnubok_list_transactions_without_documents
accounted_list_verifikat_without_documents
accounted_list_transactions_without_documents
\`\`\`
Attach an already-uploaded document to its verifikation via \`POST /documents/{id}/link\`, passing the target entry in the body:
+1 -1
View File
@@ -27,7 +27,7 @@ export const DOCS_NAV: DocsNavSection[] = [
{ label: 'Introduction', href: '/docs/api', summary: 'What the Accounted REST API is and how to authenticate.' },
{ label: 'Quickstart', href: '/docs/api/cookbook/quickstart', summary: 'Send your first invoice in five minutes.' },
{ label: 'Authentication', href: '/docs/api#authentication', summary: 'API keys, scopes, test mode.' },
{ label: 'Connect with Claude', href: '/docs/api/connect-claude', summary: 'Connect Claude via the MCP server: OAuth connector or npx gnubok-mcp bridge.' },
{ label: 'Connect with Claude', href: '/docs/api/connect-claude', summary: 'Connect Claude via the MCP server: OAuth connector or npx accounted-mcp bridge.' },
],
},
{
+1 -1
View File
@@ -183,7 +183,7 @@ export type CoreEvent =
userId: string
companyId: string
sessionId: string | null // from Mcp-Session-Id header; null if absent
client: string | null // distribution-channel marker (X-Gnubok-Client header / ?client= param, e.g. 'openclaw').
client: string | null // distribution marker (X-Accounted-Client, legacy X-Gnubok-Client, or ?client=).
// Client-supplied (allow-list-sanitized): telemetry only, never identity or authz.
}}
// tools/list: informs us whether agents are using progressive discovery
+1 -1
View File
@@ -66,7 +66,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"icon": "Terminal",
"dataPattern": "manual",
"description": "Gör bokföring via Claude, Cursor eller annan MCP-klient",
"longDescription": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
"longDescription": "Exponerar Accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
},
{
"slug": "cloud-backup",
+4 -1
View File
@@ -3,7 +3,10 @@ import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
/**
* Row-level underlag status for a booked transaction's journal entry.
*
* - 'has' : the verifikation has at least one current-version document
* - 'has' : the verifikation has at least one current-version document,
* or is referenced by a supplier invoice whose source document
* is retained (BFL 5 kap 7 §: hänvisning till underlag);
* callers merge both kinds of ids into jeIdsWithDocs
* - 'missing': the verifikation's source type requires underlag (BFL 5 kap
* 7§), has none, and is not exempted via journal_entry_no_doc_required
* - 'none' : no statement either way (system-generated source types,
+6 -2
View File
@@ -319,8 +319,7 @@
"group_company": "Company",
"group_accounting": "Accounting & tax",
"group_sales": "Sales",
"group_tools": "Tools & integrations",
"payments": "Payments"
"group_tools": "Tools & integrations"
},
"settings_payments": {
"title": "Stripe",
@@ -6016,6 +6015,11 @@
"psd2_recommended": "Recommended",
"psd2_requires_subscription": "Requires subscription",
"psd2_description": "Automatic bank transaction sync via PSD2",
"stripe_title": "Stripe",
"stripe_coming_soon": "Coming soon",
"stripe_description": "Connect your company's Stripe account to fetch payments, fees, and payouts continuously and book them against the Stripe balance.",
"stripe_not_enabled_title": "The Stripe extension is not enabled",
"stripe_not_enabled_description": "Enable the Stripe extension to connect your Stripe account and sync transactions automatically.",
"migration_title": "Import from another system",
"migration_description": "Nothing changes in your existing system.",
"bankfile_title": "Bank file",
+6 -2
View File
@@ -319,8 +319,7 @@
"group_company": "Företag",
"group_accounting": "Bokföring & skatt",
"group_sales": "Försäljning",
"group_tools": "Verktyg & integrationer",
"payments": "Betalningar"
"group_tools": "Verktyg & integrationer"
},
"settings_payments": {
"title": "Stripe",
@@ -6016,6 +6015,11 @@
"psd2_recommended": "Rekommenderat",
"psd2_requires_subscription": "Kräver abonnemang",
"psd2_description": "Automatisk hämtning av banktransaktioner via PSD2",
"stripe_title": "Stripe",
"stripe_coming_soon": "Kommer snart",
"stripe_description": "Koppla företagets Stripe-konto så hämtas betalningar, avgifter och utbetalningar löpande och bokförs mot Stripe-saldot.",
"stripe_not_enabled_title": "Stripe-tillägget är inte aktiverat",
"stripe_not_enabled_description": "Aktivera tillägget Stripe för att koppla ditt Stripe-konto och synka transaktioner automatiskt.",
"migration_title": "Hämta från annat system",
"migration_description": "Inget ändras i ditt befintliga system.",
"bankfile_title": "Bankfil",
+57
View File
@@ -0,0 +1,57 @@
# accounted-mcp
Connect Claude Desktop, Claude Code, or another stdio MCP client to your
[Accounted](https://app.accounted.se) bookkeeping account.
This zero-dependency bridge forwards JSON-RPC over stdio to the hosted Accounted
MCP server. New connections receive the `accounted_*` tool namespace. Existing
`gnubok-mcp` configurations remain supported separately.
## Setup
1. Create an API key in Accounted under **Settings > API**.
2. Add the bridge to your MCP client:
```json
{
"mcpServers": {
"accounted": {
"command": "npx",
"args": ["-y", "accounted-mcp"],
"env": {
"ACCOUNTED_API_KEY": "gnubok_sk_test_...",
"ACCOUNTED_CLIENT": "claude-desktop"
}
}
}
}
```
The credential value retains the legacy `gnubok_sk_*` wire prefix for backward
compatibility. Only the MCP integration is being renamed in this release.
## Environment variables
| Variable | Required | Default | Description |
|---|---:|---|---|
| `ACCOUNTED_API_KEY` | yes | none | Your existing Accounted API key. |
| `ACCOUNTED_URL` | no | Accounted hosted MCP endpoint | Override for self-hosted Accounted. The bridge adds `tool_namespace=accounted` when omitted. |
| `ACCOUNTED_CLIENT` | no | none | Telemetry-only distribution marker such as `claude-desktop`. |
The API key scopes determine which tools are visible and callable. Write tools
stage pending operations for explicit approval before anything is booked.
## OAuth connector
Clients with OAuth custom-connector support can connect directly without this
bridge:
```text
https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted
```
## Compatibility
The legacy `gnubok-mcp` package, environment variables, endpoint behavior, and
`gnubok_*` tool aliases remain supported. Existing installations do not need to
change.
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const packageDir = path.resolve(__dirname, '..')
const packageJson = JSON.parse(
readFileSync(path.join(packageDir, 'package.json'), 'utf8')
) as {
name: string
bin: Record<string, string>
dependencies?: Record<string, string>
}
const source = readFileSync(path.join(packageDir, 'index.mjs'), 'utf8')
describe('accounted-mcp package', () => {
it('publishes the Accounted command without runtime dependencies', () => {
expect(packageJson.name).toBe('accounted-mcp')
expect(packageJson.bin).toEqual({ 'accounted-mcp': './index.mjs' })
expect(packageJson.dependencies).toBeUndefined()
})
it('uses Accounted configuration names and preserves the API-key wire prefix', () => {
expect(source).toContain('ACCOUNTED_API_KEY')
expect(source).toContain('ACCOUNTED_URL')
expect(source).toContain('ACCOUNTED_CLIENT')
expect(source).toContain('X-Accounted-Client')
expect(source).toContain('tool_namespace')
expect(source).toContain('gnubok_sk_')
expect(source).not.toContain('GNUBOK_API_KEY')
expect(source).not.toContain('GNUBOK_URL')
expect(source).not.toContain('X-Gnubok-Client')
expect(source).not.toContain('app.gnubok.se')
})
})
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* accounted-mcp: Connect an MCP client to your Accounted bookkeeping account.
*
* Usage in claude_desktop_config.json:
* {
* "mcpServers": {
* "accounted": {
* "command": "npx",
* "args": ["-y", "accounted-mcp"],
* "env": {
* "ACCOUNTED_API_KEY": "gnubok_sk_..."
* }
* }
* }
* }
*/
const API_KEY = process.env.ACCOUNTED_API_KEY
const DEFAULT_MCP_URL =
'https://app.accounted.se/api/extensions/ext/mcp-server/mcp'
function resolveMcpUrl(rawUrl) {
try {
const url = new URL(rawUrl)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('unsupported protocol')
}
if (!url.searchParams.has('tool_namespace')) {
url.searchParams.set('tool_namespace', 'accounted')
}
return url.toString()
} catch {
process.stderr.write('accounted-mcp: ACCOUNTED_URL must be a valid HTTP(S) URL\n')
process.exit(1)
}
}
const MCP_URL = resolveMcpUrl(process.env.ACCOUNTED_URL || DEFAULT_MCP_URL)
// Optional distribution-channel marker (for example, "claude-desktop").
// Forwarded for telemetry only and never used for authentication or behavior.
const rawClient = process.env.ACCOUNTED_CLIENT
const CLIENT =
rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient : undefined
if (rawClient && !CLIENT) {
process.stderr.write(
'accounted-mcp: ignoring ACCOUNTED_CLIENT: must match [A-Za-z0-9._-]{1,64}\n'
)
}
if (!API_KEY) {
process.stderr.write(
'Error: ACCOUNTED_API_KEY is required.\n' +
'Get your API key at: https://app.accounted.se/settings?tab=api\n' +
'\n' +
'Add it to your Claude Desktop config:\n' +
'{\n' +
' "mcpServers": {\n' +
' "accounted": {\n' +
' "command": "npx",\n' +
' "args": ["-y", "accounted-mcp"],\n' +
' "env": {\n' +
' "ACCOUNTED_API_KEY": "gnubok_sk_..."\n' +
' }\n' +
' }\n' +
' }\n' +
'}\n'
)
process.exit(1)
}
let buffer = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buffer += chunk
let newlineIdx
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim()
buffer = buffer.slice(newlineIdx + 1)
if (!line) continue
handleMessage(line).catch((err) => {
process.stderr.write(`accounted-mcp error: ${err.message}\n`)
})
}
})
process.stdin.on('end', () => {
process.exit(0)
})
async function handleMessage(line) {
let parsed
try {
parsed = JSON.parse(line)
} catch {
process.stderr.write('accounted-mcp: invalid JSON\n')
return
}
const isNotification = parsed.id === undefined || parsed.id === null
try {
const res = await fetch(MCP_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
...(CLIENT ? { 'X-Accounted-Client': CLIENT } : {}),
},
body: line,
})
if (res.status === 202 || res.status === 204) {
return
}
const responseText = await res.text()
// Guard against non-JSON error responses such as CDN or proxy pages.
if (!res.ok && !isNotification) {
let message = `HTTP ${res.status}`
try {
const json = JSON.parse(responseText)
if (json.error) {
message =
typeof json.error === 'string'
? json.error
: JSON.stringify(json.error)
}
} catch {
// The body was not JSON: use the generic HTTP status message.
}
const errorResponse = JSON.stringify({
jsonrpc: '2.0',
id: parsed.id,
error: { code: -32000, message },
})
process.stdout.write(`${errorResponse}\n`)
return
}
if (responseText) {
process.stdout.write(`${responseText}\n`)
}
} catch (err) {
if (!isNotification) {
const errorResponse = JSON.stringify({
jsonrpc: '2.0',
id: parsed.id,
error: { code: -32000, message: `Connection error: ${err.message}` },
})
process.stdout.write(`${errorResponse}\n`)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "accounted-mcp",
"version": "1.0.0",
"description": "Connect MCP clients to your Accounted bookkeeping account",
"bin": {
"accounted-mcp": "./index.mjs"
},
"type": "module",
"license": "MIT",
"keywords": [
"mcp",
"accounted",
"bookkeeping",
"claude",
"accounting"
],
"repository": {
"type": "git",
"url": "https://github.com/erp-mafia/accounted"
},
"engines": {
"node": ">=18"
},
"files": [
"index.mjs",
"README.md"
]
}
+5
View File
@@ -1,5 +1,10 @@
# gnubok-mcp
Legacy compatibility package for existing MCP configurations. New installations
should use [`accounted-mcp`](https://www.npmjs.com/package/accounted-mcp).
This package, its environment variables, and all existing API keys remain
supported.
Connect [Claude Desktop](https://claude.ai/download) (or any stdio MCP client) to your [Accounted](https://app.gnubok.se) bookkeeping account. This is a thin stdio → HTTPS bridge: it forwards JSON-RPC over stdio to the hosted Accounted MCP server, which exposes 90+ bookkeeping tools (invoices, transactions, VAT/momsdeklaration, payroll, reports, year-end).
Write tools stage a pending operation that you confirm before anything is booked: the bridge never books on its own.
@@ -0,0 +1,360 @@
-- Underlag reference awareness for the missing-document surfaces.
--
-- Problem (support case 2026-07-24): verifikat booked from supplier invoices
-- were flagged "saknar underlag" although the invoice's source document is
-- retained. Two flows produced the false positives:
--
-- 1. Payment verifikat (supplier_invoice_paid): the invoice document
-- deliberately hangs on the REGISTRATION verifikat (one
-- document_attachments row can only point at one journal entry), so the
-- payment entry never has a direct doc row. BFL 5 kap 7 § allows a
-- verifikation to satisfy the underlag requirement by hänvisning till
-- underlag; the payment entry's FK reference to the supplier invoice
-- (whose document is archived under WORM) is exactly that. The UI's
-- reference resolver (lib/core/bookkeeping/journal-entry-references.ts)
-- already treats it as underlag; the RPCs did not, so the row-expand view
-- showed a document while the list warning persisted.
--
-- 2. Documents pinned to a bank transaction before the transaction was
-- matched to a supplier invoice: the match routes did not propagate
-- transactions.document_id onto the created payment verifikat (the
-- categorize route does). Fixed in the routes in the same change; the
-- backfill below repairs rows already written.
--
-- The predicate: an entry is NOT missing underlag when a supplier invoice
-- referencing it (registration_journal_entry_id, payment_journal_entry_id, or
-- a supplier_invoice_payments row) carries a document that is ANCHORED to a
-- journal entry (document_attachments.journal_entry_id IS NOT NULL). The
-- anchor requirement is what makes the hänvisning legally safe: every
-- deletion guard (deleteDocument, block_document_deletion) keys on
-- journal_entry_id, so an unanchored doc is deletable and must NOT silence
-- the warning: the nag is the mechanism that gets it anchored. Anchored docs
-- are WORM-protected and the supersession RPC (create_document_version)
-- guarantees every retained chain has a readable current version. Customer
-- invoices carry no document_id and their source types are not in the
-- needs-doc list, so they stay out of the predicate.
--
-- Keep the needs-doc source-type list in lockstep with NEEDS_DOC_SOURCE_TYPES
-- (lib/worklist/categories.ts); pinned by
-- tests/pg/document-surfaces-unification.pg.test.ts.
--
-- pg-test: tests/pg/document-surfaces-unification.pg.test.ts
-- ────────────────────────────────────────────────────────────────────
-- 1. Verifikat surface
-- ────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.verifikat_without_documents(
p_company_id uuid,
p_since date DEFAULT NULL,
p_min_amount numeric DEFAULT 0,
p_limit integer DEFAULT 20,
p_offset integer DEFAULT 0
)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100);
v_offset integer := greatest(coalesce(p_offset, 0), 0);
v_min numeric := greatest(coalesce(p_min_amount, 0), 0);
v_result jsonb;
BEGIN
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF p_company_id IS NULL OR NOT EXISTS (
SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN');
END IF;
END IF;
WITH candidates AS (
SELECT
je.id,
je.voucher_series,
je.voucher_number,
je.entry_date,
je.description,
je.source_type,
round(coalesce(sum(l.debit_amount), 0), 2) AS gross_amount
FROM journal_entries je
LEFT JOIN journal_entry_lines l ON l.journal_entry_id = je.id
WHERE je.company_id = p_company_id
AND je.status = 'posted'
-- Only source types whose affärshändelse requires an underlag.
-- Mirrors NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts).
AND je.source_type IN (
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import'
)
-- Superseded document versions do not satisfy BFL underlag.
AND NOT EXISTS (
SELECT 1 FROM document_attachments d
WHERE d.journal_entry_id = je.id AND d.is_current_version = true
)
-- Explicitly waived (e.g. internal transfers): user decided no
-- underlag is required; do not resurface to agents.
AND NOT EXISTS (
SELECT 1 FROM journal_entry_no_doc_required x
WHERE x.journal_entry_id = je.id
)
-- BFL 5 kap 7 §: hänvisning till underlag. An entry booked from a
-- supplier invoice whose source document is retained is covered by
-- that document even though the doc row hangs on the invoice's other
-- verifikat (registration vs payment). The doc must be ANCHORED
-- (journal_entry_id set): only anchored docs sit behind the WORM
-- deletion guards, so an unanchored doc cannot legally back a posted
-- verifikat and must keep the warning alive.
AND NOT EXISTS (
SELECT 1
FROM supplier_invoices si
JOIN document_attachments sd ON sd.id = si.document_id
WHERE si.company_id = p_company_id
AND sd.journal_entry_id IS NOT NULL
AND (si.registration_journal_entry_id = je.id
OR si.payment_journal_entry_id = je.id)
)
-- Partial payments link through supplier_invoice_payments instead of
-- supplier_invoices.payment_journal_entry_id.
AND NOT EXISTS (
SELECT 1
FROM supplier_invoice_payments sip
JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id
JOIN document_attachments sipd ON sipd.id = sip_si.document_id
WHERE sip.journal_entry_id = je.id
AND sip_si.company_id = p_company_id
AND sipd.journal_entry_id IS NOT NULL
)
AND (p_since IS NULL OR je.entry_date >= p_since)
GROUP BY je.id
HAVING round(coalesce(sum(l.debit_amount), 0), 2) >= v_min
),
total AS (
SELECT count(*) AS n FROM candidates
),
page AS (
SELECT * FROM candidates
ORDER BY entry_date DESC, voucher_number DESC, id DESC
LIMIT v_limit OFFSET v_offset
)
SELECT jsonb_build_object(
'ok', true,
'total_count', (SELECT n FROM total),
'verifikat', coalesce(
(SELECT jsonb_agg(
jsonb_build_object(
'journal_entry_id', p.id,
'voucher_series', p.voucher_series,
'voucher_number', p.voucher_number,
'entry_date', p.entry_date,
'description', p.description,
'source_type', p.source_type,
'gross_amount', p.gross_amount
)
ORDER BY p.entry_date DESC, p.voucher_number DESC, p.id DESC
) FROM page p),
'[]'::jsonb
)
)
INTO v_result;
RETURN v_result;
END;
$$;
REVOKE ALL ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) TO authenticated, service_role;
-- ────────────────────────────────────────────────────────────────────
-- 2. Transactions surface: the bank-driven subset of the same predicate
-- ────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.transactions_without_documents(
p_company_id uuid,
p_since date DEFAULT NULL,
p_limit integer DEFAULT 20,
p_offset integer DEFAULT 0
)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100);
v_offset integer := greatest(coalesce(p_offset, 0), 0);
v_result jsonb;
BEGIN
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF p_company_id IS NULL OR NOT EXISTS (
SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN');
END IF;
END IF;
WITH candidates AS (
SELECT
t.id,
t.date,
t.description,
t.amount,
t.currency,
t.merchant_name,
t.reference,
t.is_business,
t.category,
t.journal_entry_id
FROM transactions t
JOIN journal_entries je ON je.id = t.journal_entry_id
WHERE t.company_id = p_company_id
AND je.status = 'posted'
-- Same predicate as verifikat_without_documents: this surface is the
-- bank-driven subset, keyed on the SAME document truth
-- (document_attachments), never transactions.document_id.
AND je.source_type IN (
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import'
)
AND NOT EXISTS (
SELECT 1 FROM document_attachments d
WHERE d.journal_entry_id = je.id AND d.is_current_version = true
)
AND NOT EXISTS (
SELECT 1 FROM journal_entry_no_doc_required x
WHERE x.journal_entry_id = je.id
)
-- BFL 5 kap 7 § hänvisning till underlag (anchored docs only); see
-- verifikat_without_documents.
AND NOT EXISTS (
SELECT 1
FROM supplier_invoices si
JOIN document_attachments sd ON sd.id = si.document_id
WHERE si.company_id = p_company_id
AND sd.journal_entry_id IS NOT NULL
AND (si.registration_journal_entry_id = je.id
OR si.payment_journal_entry_id = je.id)
)
AND NOT EXISTS (
SELECT 1
FROM supplier_invoice_payments sip
JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id
JOIN document_attachments sipd ON sipd.id = sip_si.document_id
WHERE sip.journal_entry_id = je.id
AND sip_si.company_id = p_company_id
AND sipd.journal_entry_id IS NOT NULL
)
AND (p_since IS NULL OR t.date >= p_since)
),
total AS (
SELECT count(*) AS n FROM candidates
),
page AS (
SELECT * FROM candidates
ORDER BY date DESC, id DESC
LIMIT v_limit OFFSET v_offset
)
SELECT jsonb_build_object(
'ok', true,
'total_count', (SELECT n FROM total),
'transactions', coalesce(
(SELECT jsonb_agg(
jsonb_build_object(
'id', p.id,
'transaction_id', p.id,
'date', p.date,
'description', p.description,
'amount', p.amount,
'currency', p.currency,
'merchant_name', p.merchant_name,
'reference', p.reference,
'is_business', p.is_business,
'category', p.category,
'journal_entry_id', p.journal_entry_id
)
ORDER BY p.date DESC, p.id DESC
) FROM page p),
'[]'::jsonb
)
)
INTO v_result;
RETURN v_result;
END;
$$;
REVOKE ALL ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) TO authenticated, service_role;
-- ────────────────────────────────────────────────────────────────────
-- 3. Supporting indexes: the reference anti-joins probe supplier_invoices by
-- journal-entry FK and supplier_invoice_payments by journal_entry_id;
-- none of these carry an index by default. Partial on document_id so the
-- index only holds rows that can actually satisfy the predicate.
-- ────────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_registration_je_doc
ON public.supplier_invoices (registration_journal_entry_id)
WHERE document_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_payment_je_doc
ON public.supplier_invoices (payment_journal_entry_id)
WHERE document_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_journal_entry_id
ON public.supplier_invoice_payments (journal_entry_id);
-- ────────────────────────────────────────────────────────────────────
-- 4. Backfill the match-flow propagation gap: docs pinned to a booked
-- transaction whose verifikat never received the document_attachments
-- link. Same shape as 20260703160000 §3 and idempotent alongside it:
-- only currently-unlinked docs (never steal a doc that points at another
-- verifikat) and only into open, unlocked periods (enforce_period_lock
-- raises on journal_entry_id writes in locked/closed periods).
-- ────────────────────────────────────────────────────────────────────
DO $$
DECLARE
v_updated integer;
BEGIN
WITH gap AS (
SELECT t.document_id, t.journal_entry_id, t.company_id
FROM transactions t
JOIN journal_entries je ON je.id = t.journal_entry_id
JOIN fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE t.document_id IS NOT NULL
AND je.status = 'posted'
AND fp.is_closed = false
AND fp.locked_at IS NULL
)
UPDATE document_attachments d
SET journal_entry_id = gap.journal_entry_id
FROM gap
WHERE d.id = gap.document_id
-- Tenancy guard (defense in depth; the attach routes enforce it
-- app-side, but a corrupt cross-company pin must not be welded into an
-- immutable underlag link here).
AND d.company_id = gap.company_id
AND d.journal_entry_id IS NULL
AND d.is_current_version = true;
GET DIAGNOSTICS v_updated = ROW_COUNT;
RAISE NOTICE 'underlag_reference_awareness: propagated % transaction-pinned documents to their verifikat', v_updated;
END;
$$;
NOTIFY pgrst, 'reload schema';
+268 -10
View File
@@ -12,9 +12,17 @@ import {
/**
* P1-3 (mcp_optimization_plan): both missing-document surfaces implement ONE
* predicate: posted, needs-doc source type, no CURRENT-version
* document_attachments row, no journal_entry_no_doc_required waiver: and the
* document_attachments row, no journal_entry_no_doc_required waiver, no
* supplier-invoice reference carrying a retained document: and the
* transactions surface is a strict subset of the verifikat surface.
*
* The supplier-invoice arm (migration 20260724090000) implements BFL 5 kap
* 7 §: a verifikation may satisfy the underlag requirement by hänvisning till
* underlag. An entry referenced by a supplier invoice whose document_id is
* set (registration/payment FK or a supplier_invoice_payments row) is
* covered by that retained document even though the doc row hangs on the
* invoice's other verifikat.
*
* Also pins the SQL needs-doc source-type list to the TS constant
* NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts): a divergence between
* the two lists fails the per-source-type probe below.
@@ -51,7 +59,7 @@ async function transactionsSurface(companyId: string): Promise<TransactionsResul
async function attachDocument(params: {
userId: string
companyId: string
journalEntryId: string
journalEntryId: string | null
isCurrentVersion?: boolean
}): Promise<string> {
const id = randomUUID()
@@ -81,6 +89,61 @@ async function waive(params: { userId: string; companyId: string; journalEntryId
)
}
async function insertSupplier(params: { userId: string; companyId: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Leverantör AB')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
arrivalNumber: number
registrationJournalEntryId?: string | null
paymentJournalEntryId?: string | null
documentId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, total, remaining_amount,
registration_journal_entry_id, payment_journal_entry_id, document_id)
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-06-30', 1000, 1000, $7, $8, $9)`,
[
id,
params.userId,
params.companyId,
params.supplierId,
params.arrivalNumber,
`SI-${params.arrivalNumber}`,
params.registrationJournalEntryId ?? null,
params.paymentJournalEntryId ?? null,
params.documentId ?? null,
],
)
return id
}
async function insertSupplierInvoicePayment(params: {
userId: string
companyId: string
supplierInvoiceId: string
journalEntryId: string
}): Promise<void> {
await getPool().query(
`INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, journal_entry_id)
VALUES ($1, $2, $3, '2026-06-10', 500, $4)`,
[params.userId, params.companyId, params.supplierInvoiceId, params.journalEntryId],
)
}
describe('document surfaces unification', () => {
let userId: string
let companyId: string
@@ -93,6 +156,11 @@ describe('document surfaces unification', () => {
let jeBankStaleDoc: string // bank tx JE, only superseded doc version → BOTH
let jeInvoiceCreated: string // doc-exempt source type → NEITHER
let jeImportNoDoc: string // import JE, no tx → verifikat surface only
let jeSiRegWithDoc: string // SI registration JE holding the invoice doc directly → NEITHER
let jeSiPaymentCovered: string // SI payment JE, doc on the SI (registration side) → NEITHER (BFL 5:7 hänvisning)
let jeSiRegNoDoc: string // SI registration JE, SI has NO doc → verifikat surface
let jeSiPartialCovered: string // SI payment JE referenced only via supplier_invoice_payments, SI doc anchored → NEITHER
let jeSiPayUnanchored: string // SI payment JE whose SI doc is UNANCHORED (deletable) → verifikat surface
beforeAll(async () => {
const s = await seedCompany()
@@ -107,7 +175,7 @@ describe('document surfaces unification', () => {
fiscalPeriodId,
status: 'posted',
voucherNumber: n,
entryDate: `2026-06-0${n}`,
entryDate: `2026-06-${String(n).padStart(2, '0')}`,
description: `${sourceType} ${n}`,
sourceType,
})
@@ -121,16 +189,23 @@ describe('document surfaces unification', () => {
jeBankStaleDoc = await mkJe(4, 'bank_transaction')
jeInvoiceCreated = await mkJe(5, 'invoice_created')
jeImportNoDoc = await mkJe(6, 'import')
jeSiRegWithDoc = await mkJe(7, 'supplier_invoice_registered')
jeSiPaymentCovered = await mkJe(8, 'supplier_invoice_paid')
jeSiRegNoDoc = await mkJe(9, 'supplier_invoice_registered')
jeSiPartialCovered = await mkJe(10, 'supplier_invoice_paid')
jeSiPayUnanchored = await mkJe(11, 'supplier_invoice_paid')
// Bank transactions pointing at the four bank-driven entries. The
// with-doc tx deliberately keeps document_id NULL (the 1,100-row reverse
// gap on prod): the surface must key on document_attachments, not
// transactions.document_id.
// Bank transactions pointing at the bank-driven entries. The with-doc tx
// deliberately keeps document_id NULL (the 1,100-row reverse gap on
// prod): the surface must key on document_attachments, not
// transactions.document_id. jeSiPaymentCovered also gets a tx so the
// transactions surface exercises the reference arm.
for (const [jeId, date] of [
[jeBankNoDoc, '2026-06-01'],
[jeBankWithDoc, '2026-06-02'],
[jeBankWaived, '2026-06-03'],
[jeBankStaleDoc, '2026-06-04'],
[jeSiPaymentCovered, '2026-06-08'],
] as const) {
await insertTransaction({ userId, companyId, journalEntryId: jeId, date })
}
@@ -143,24 +218,98 @@ describe('document surfaces unification', () => {
isCurrentVersion: false,
})
await waive({ userId, companyId, journalEntryId: jeBankWaived })
// Supplier-invoice reference matrix (BFL 5 kap 7 § hänvisning):
// - siWithDoc: document hangs on the registration JE; its payment JE is
// covered by reference through payment_journal_entry_id.
// - siNoDoc: no retained document → its registration JE stays flagged.
// - siPartial: anchored document; its payment JE is linked only through
// a supplier_invoice_payments row (partial payment path).
// - siUnanchored: document referenced but journal_entry_id NULL: outside
// the WORM deletion guards, so it must NOT silence the warning.
const supplierId = await insertSupplier({ userId, companyId })
const siDoc = await attachDocument({ userId, companyId, journalEntryId: jeSiRegWithDoc })
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeSiRegWithDoc,
paymentJournalEntryId: jeSiPaymentCovered,
documentId: siDoc,
})
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 2,
registrationJournalEntryId: jeSiRegNoDoc,
documentId: null,
})
// Anchor the partial invoice's doc on the covered registration JE so the
// partial arm is exercised in isolation (the doc's own anchor is a
// different entry than the one being silenced).
const siPartialDoc = await attachDocument({ userId, companyId, journalEntryId: jeSiRegWithDoc })
const siPartial = await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 3,
documentId: siPartialDoc,
})
await insertSupplierInvoicePayment({
userId,
companyId,
supplierInvoiceId: siPartial,
journalEntryId: jeSiPartialCovered,
})
const siUnanchoredDoc = await attachDocument({ userId, companyId, journalEntryId: null })
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 4,
paymentJournalEntryId: jeSiPayUnanchored,
documentId: siUnanchoredDoc,
})
})
it('verifikat surface: needs-doc entries without current docs or waivers, nothing else', async () => {
it('verifikat surface: needs-doc entries without current docs, waivers, or covering references', async () => {
const res = await verifikatSurface(companyId)
expect(res.ok).toBe(true)
const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id).sort()
expect(ids).toEqual([jeBankNoDoc, jeBankStaleDoc, jeImportNoDoc].sort())
expect(res.total_count).toBe(3)
// jeSiRegNoDoc appears: its supplier invoice retains no document, so the
// reference alone is not underlag. jeSiPayUnanchored appears: the SI's
// doc is not anchored to any entry, so it is deletable and cannot back a
// posted verifikat. The covered SI entries do not appear.
expect(ids).toEqual(
[jeBankNoDoc, jeBankStaleDoc, jeImportNoDoc, jeSiRegNoDoc, jeSiPayUnanchored].sort(),
)
expect(res.total_count).toBe(5)
// Doc-exempt source type never appears even when undocumented.
expect(ids).not.toContain(jeInvoiceCreated)
})
it('supplier-invoice references with an anchored doc silence both FK paths and the partial-payment path', async () => {
const res = await verifikatSurface(companyId)
const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id)
// Registration JE holds the doc directly.
expect(ids).not.toContain(jeSiRegWithDoc)
// Payment JE covered by the SI's retained doc via payment_journal_entry_id.
expect(ids).not.toContain(jeSiPaymentCovered)
// Payment JE covered via a supplier_invoice_payments row only.
expect(ids).not.toContain(jeSiPartialCovered)
// Unanchored SI doc does NOT cover its payment JE.
expect(ids).toContain(jeSiPayUnanchored)
})
it('transactions surface: the bank-driven rows of the same set, keyed on document_attachments', async () => {
const res = await transactionsSurface(companyId)
expect(res.ok).toBe(true)
const jeIds = (res.transactions ?? []).map((t) => t.journal_entry_id).sort()
// jeBankWithDoc excluded even though its tx.document_id is NULL: the
// doc truth is document_attachments. jeImportNoDoc has no tx row.
// jeSiPaymentCovered excluded: covered by the SI's retained doc.
expect(jeIds).toEqual([jeBankNoDoc, jeBankStaleDoc].sort())
// P1-2 forward-compat: rows expose the qualified id.
expect(res.transactions![0].transaction_id).toBe(res.transactions![0].id)
@@ -209,3 +358,112 @@ describe('document surfaces unification', () => {
expect((rows[0].r.transactions ?? []).length).toBe(0)
})
})
describe('transaction-pinned document backfill (migration 20260724090000 §4)', () => {
// The DO-block body, verbatim from the migration: docs pinned to a booked
// transaction whose verifikat never received the link. Only unlinked
// current-version docs, only into open unlocked periods.
const BACKFILL_SQL = `
WITH gap AS (
SELECT t.document_id, t.journal_entry_id
FROM transactions t
JOIN journal_entries je ON je.id = t.journal_entry_id
JOIN fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE t.document_id IS NOT NULL
AND je.status = 'posted'
AND fp.is_closed = false
AND fp.locked_at IS NULL
)
UPDATE document_attachments d
SET journal_entry_id = gap.journal_entry_id
FROM gap
WHERE d.id = gap.document_id
AND d.journal_entry_id IS NULL
AND d.is_current_version = true`
it('propagates unlinked pinned docs, never steals linked docs, skips closed periods', async () => {
const s = await seedCompany()
const mkPostedJe = async (n: number, fiscalPeriodId: string) => {
const id = await insertDraftJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId,
status: 'posted',
voucherNumber: n,
entryDate: '2026-06-15',
description: `backfill ${n}`,
sourceType: 'supplier_invoice_paid',
})
await insertBalancedLines(id, 100 * n)
return id
}
// Case A (Emil's flow): doc pinned to the tx, never propagated.
const jeA = await mkPostedJe(1, s.fiscalPeriodId)
const docA = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: null })
const txA = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeA,
date: '2026-06-15',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docA, txA])
// Case B: pinned doc already serves ANOTHER verifikat: must not move.
const jeB = await mkPostedJe(2, s.fiscalPeriodId)
const jeBOther = await mkPostedJe(3, s.fiscalPeriodId)
const docB = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: jeBOther })
const txB = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeB,
date: '2026-06-16',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docB, txB])
await getPool().query(BACKFILL_SQL)
const { rows: aRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docA],
)
expect(aRows[0].journal_entry_id).toBe(jeA)
const { rows: bRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docB],
)
expect(bRows[0].journal_entry_id).toBe(jeBOther)
// Case A no longer surfaces as missing underlag.
const res = await verifikatSurface(s.companyId)
const flagged = (res.verifikat ?? []).map((v) => v.journal_entry_id)
expect(flagged).not.toContain(jeA)
// Case C: closed period: the gap row is filtered out, so the doc stays
// unlinked and no period-lock trigger fires. Closing happens AFTER the
// entries exist (inserting into a closed period would itself be blocked).
const jeC = await mkPostedJe(4, s.fiscalPeriodId)
const docC = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: null })
const txC = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeC,
date: '2026-06-17',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docC, txC])
await getPool().query(
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
await getPool().query(BACKFILL_SQL)
const { rows: cRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docC],
)
expect(cRows[0].journal_entry_id).toBeNull()
})
})