Bug/year end numbers (#744)

* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

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

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

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

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

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

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

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

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-16 18:22:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5c40fa9aeb
commit 2a8bf9b42e
27 changed files with 1860 additions and 174 deletions
@@ -44,6 +44,8 @@ function mockSupabase(opts: {
title: string | null
description: string
}>
// profiles.full_name for the signed-in user. undefined → no profile row.
userFullName?: string | null
errors?: { profile?: string; memory?: string; atoms?: string }
}) {
const profile = opts.profile === undefined ? null : opts.profile
@@ -53,6 +55,19 @@ function mockSupabase(opts: {
return {
from: vi.fn((table: string) => {
if (table === 'profiles') {
return {
select: vi.fn(() => ({
eq: vi.fn(() => ({
maybeSingle: vi.fn().mockResolvedValue({
data:
opts.userFullName === undefined ? null : { full_name: opts.userFullName },
error: null,
}),
})),
})),
}
}
if (table === 'agent_profiles') {
return {
select: vi.fn(() => ({
@@ -120,7 +135,7 @@ describe('gnubok_get_agent_briefing tool', () => {
expect(input.required).toBeUndefined()
})
it('returns null summary, empty atoms, empty memory when no profile exists', async () => {
it('returns null summary, empty atoms, empty memory, null user_name when nothing exists', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const supabase = mockSupabase({ profile: null })
const result = (await tool.execute(
@@ -130,6 +145,7 @@ describe('gnubok_get_agent_briefing tool', () => {
supabase as never,
{ type: 'api_key' }
)) as {
user_name: string | null
profile_summary: string | null
atoms: unknown[]
memory: unknown[]
@@ -137,6 +153,34 @@ describe('gnubok_get_agent_briefing tool', () => {
expect(result.profile_summary).toBeNull()
expect(result.atoms).toEqual([])
expect(result.memory).toEqual([])
expect(result.user_name).toBeNull()
})
it('returns only the first name (tilltalsnamn) — data minimisation, not the full legal name', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const supabase = mockSupabase({ profile: null, userFullName: 'Peter Bennet' })
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as { user_name: string | null }
// GDPR Art.5(1)(c): the surname never enters the LLM prompt.
expect(result.user_name).toBe('Peter')
})
it('treats a blank full_name as no name (null)', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const supabase = mockSupabase({ profile: null, userFullName: ' ' })
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as { user_name: string | null }
expect(result.user_name).toBeNull()
})
it('returns profile + atom metadata + memory when populated', async () => {
+40 -14
View File
@@ -1616,7 +1616,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
description: 'List available domain-knowledge skills filtered to this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.',
description: 'List domain-knowledge skills for this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2062,7 +2062,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_agent_briefing',
title: 'Get Agent Briefing',
description: 'Bootstrap this company\'s accountant context in one call: profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2072,6 +2072,11 @@ export const tools: McpTool[] = [
type: 'object',
additionalProperties: false,
properties: {
user_name: {
type: ['string', 'null'],
description:
'Name of the person you are assisting — address them by it (their tilltalsnamn), not the owner in profile_summary. Null if unset.',
},
profile_summary: {
type: ['string', 'null'],
description: 'Composer-generated one-paragraph summary of the company. Null if no agent profile exists yet (composer has not run).',
@@ -2107,7 +2112,7 @@ export const tools: McpTool[] = [
},
},
},
required: ['profile_summary', 'atoms', 'memory'],
required: ['user_name', 'profile_summary', 'atoms', 'memory'],
},
annotations: {
readOnlyHint: true,
@@ -2115,8 +2120,8 @@ export const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
const [profileRes, memoryRes] = await Promise.all([
async execute(_args, companyId, userId, supabase) {
const [profileRes, memoryRes, userRes] = await Promise.all([
supabase
.from('agent_profiles')
.select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms')
@@ -2130,6 +2135,16 @@ export const tools: McpTool[] = [
.order('relevance_score', { ascending: false, nullsFirst: false })
.order('last_accessed_at', { ascending: false, nullsFirst: false })
.limit(30),
// The user's own preferred name (profiles.full_name) so the agent can
// address them correctly. Distinct from owner/signatory names that may
// appear in profile_summary — those come from Bolagsverket via TIC and
// describe the company, not necessarily the person chatting. Best-effort:
// a failed read yields a null name, never a thrown briefing.
supabase
.from('profiles')
.select('full_name')
.eq('id', userId)
.maybeSingle(),
])
if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`)
@@ -2150,6 +2165,16 @@ export const tools: McpTool[] = [
relevance_score: number | null
}>
// profiles read is best-effort — ignore userRes.error so a missing name
// never blocks the briefing. Data minimisation (GDPR Art.5(1)(c)): the
// agent only needs the tilltalsnamn to address the user, so pass the first
// token only — never the full legal name — into the LLM prompt. Mirrors
// app/api/agent/invoke/route.ts, which also derives firstName via split.
const userName =
(((userRes.data as { full_name: string | null } | null)?.full_name ?? '')
.trim()
.split(/\s+/)[0] || null)
const atomIds = [
...(profile?.horizontal_atoms ?? []),
...(profile?.vertical_atoms ?? []),
@@ -2178,6 +2203,7 @@ export const tools: McpTool[] = [
}
return {
user_name: userName,
profile_summary: profile?.profile_summary ?? null,
atoms,
memory: memoryRows.map((m) => ({
@@ -2555,7 +2581,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides the computed moms; reverse_charge is rejected when the underlag shows the seller already charged VAT.',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge is rejected when the underlag shows the seller charged VAT.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4242,7 +4268,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_general_ledger',
title: 'General Ledger (Huvudbok)',
description: 'General ledger (huvudbok) for a fiscal period: per-account opening balance, entries, closing balance. Optional account range filter. For ad-hoc cross-account, amount, or free-text line queries use gnubok_query_journal.',
description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range filter. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4737,7 +4763,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_match_batch_allocate',
title: 'Batch-Allocate Payment',
description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Customer needs income, supplier expense. Stages.',
description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Stages.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5333,7 +5359,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_invoice_to_voucher',
title: 'Link Invoice to Voucher',
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_invoice först.',
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Kör gnubok_find_voucher_candidates_for_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6310,7 +6336,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_unmatched_documents',
title: 'List Unmatched Documents',
description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.',
description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6532,7 +6558,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_attach_document_to_transaction',
title: 'Attach Document to Transaction',
description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the preview shown to the human reviewer mirrors what you pass here. Stages for approval.',
description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the reviewer\'s preview mirrors what you pass here. Stages for approval.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8408,7 +8434,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_voucher',
title: 'Create Manual Voucher (Verifikation)',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct (links + attaches doc). HIGH risk.',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8809,7 +8835,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_reverse_journal_entry',
title: 'Reverse Journal Entry (Storno)',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Only when the affärshändelse should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.',
description: 'Stage a storno: inverts debits/credits, original stays visible (BFL 5 kap). Only when it should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -9546,7 +9572,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_set_inbox_extracted_data',
title: 'Set Inbox Extracted Data',
description: 'Replace extracted_data on an inbox item with agent-supplied fields (bring-your-own-extraction). Use when your own pipeline parses the document better than Accounted\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.',
description: 'Replace extracted_data on an inbox item with agent-supplied fields. Use when your pipeline parses the document better than Accounted\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.',
inputSchema: {
type: 'object',
additionalProperties: false,