From 4131db2894377d3b7a13025de2e729d2e976cb5a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 6 May 2026 16:41:36 +0200 Subject: [PATCH] chore: MCP intent-tools, BankID enrichment table, multi-tenant fixes (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: MCP intent-tools, BankID enrichment table, multi-tenant fixes MCP server gains six intent-shaped tools that collapse multi-call agent flows into one: vat_close_check, query_journal, auto_match_period, create_supplier_invoice_from_inbox, audit_package, year_end_readiness. Tools wired into TOOL_SCOPE_MAP and OPERATION_RISK_TIERS as appropriate (create_supplier_invoice_from_inbox at medium tier — reversible until approve, but stages a leverantörsskuld). BankID enrichment now persists to a dedicated bankid_enrichment table keyed by user_id. extension_data has been company-scoped (NOT NULL company_id) since the multi-tenant refactor, so every BankID signup has silently been failing the enrichment upsert. Select-company picker reads from the new table. delete_last_voucher (BFNAR 2013:2) needs to clear document_attachments.journal_entry_id before deleting the entry, but the new document immutability trigger blocks that UPDATE. Added the same gnubok.allow_delete transaction-scoped bypass pattern used by the journal-entry/line/retention triggers. pg-real tests cover the happy path, the unauthorized direct UPDATE, and the swap-to-different-entry attempt under the bypass flag. fiscal_periods.no_overlapping_fiscal_periods exclusion was scoped to user_id from before multi-tenant — rebound to company_id so the same user can have overlapping fiscal years across companies they own/are member of. Also adds scripts/seed-demo-account.ts for end-to-end demo seeding (two companies, full FY2025, active FY2026 with mixed state). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(pr-402): address review feedback Migrations - Drop 20260506140000_document_journal_entry_immutability_delete_bypass.sql: redundant with 20260506140000_document_journal_entry_immutability_bypass.sql that landed on main while this branch was open. Both share the same gnubok.allow_delete pattern; main's version is what the DB actually has. - Rename 20260506150000_bankid_enrichment_table.sql → 20260506160000_bankid_enrichment_table.sql to clear the timestamp clash with 20260506150000_protect_document_journal_link.sql on main (Supabase branch preview was failing on schema_migrations PK collision). Tests - Drop the swap-under-flag test from delete-last-voucher.pg.test.ts: main's bypass returns NEW unconditionally when gnubok.allow_delete='true', so the swap is permitted. Drop the duplicate happy-path test (already covered by 'clears journal_entry_id on attached documents and deletes the voucher'). Keep the unauthorized-direct-UPDATE test. - Add bankid-enrichment.pg.test.ts covering the SELECT RLS policy: user reads own row, cannot read another user's row, INSERT denied for authenticated. gnubok_query_journal - amount_min/amount_max is applied post-fetch (PostgREST can't OR abs(debit) and abs(credit) cleanly), but PostgREST's count is computed pre-filter. Reporting that as total_lines mislead agents into paginating a tail that was already filtered out. When the amount filter is applied, anchor total_lines and truncated to the filtered set and surface db_matched_pre_amount_filter + amount_filter_applied_post_fetch separately. - Escape `_` in the free-text LIKE filter so a search for "2_441" doesn't match "2X441". VAT close check - Reverse-charge blocker no longer fires on ruta 30 (seller-side domestic omvänd skattskyldighet) — the seller books no VAT, the buyer does, so missing ruta 48 is expected. Now scoped to ruta 31/32 (EU acquisition) where the buyer must book both calculated output (2615) and matching ingående moms (2645). - High-value receipt threshold no longer reads journal_entries.total_amount (column doesn't exist; check silently never fired). Sums debits across the entry's lines, which equals the gross for ordinary purchase entries — comparing a gross figure against the BFL/ML 4 000 SEK threshold per ML 17 kap 26–28 §. seed-demo-account.ts - Require an explicit email argument; refuse to run with the previously hardcoded fallback that would silently target a real user. Ensure email is non-undefined for downstream typing. - Type the supabase fiscal_periods insert result locally so tsc no longer reports 'fp implicitly any' from the loose untyped client. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): adjust fiscal-period-start-day pg test for per-company overlap The pg-real failure on PR #402 was a latent bug surfaced by this branch's fiscal_periods exclusion constraint flip from user_id to company_id (migration 20260506140100). The test was inserting periods that overlapped seedCompany's default 2026-01-01..2026-12-31 period; the previous constraint slipped past it because the test's INSERT didn't set user_id (NULL escapes the WITH = match), so two same-company overlapping periods silently coexisted. Now that the constraint correctly fires per company, pick years that don't overlap with the seeded 2026 period. The trigger's behavior under test (allow mid-month start when no earlier period exists, allow back-dated SIE imports, reject mid-month start when an earlier period exists) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(vat-close-check): correct reverse-charge/import blocker rutor Rutor 30/31/32 are the buyer's calculated utgående moms on reverse- charge purchases (domestic byggtjänster/electronics → 2614 → ruta 30; EU goods → 2624 → ruta 31; EU services → 2634 → ruta 32). The buyer must also book matching ingående moms (2647 inhemskt / 2645 utlandet → ruta 48). The previous fix removed ruta 30 on the basis that it was seller-side; that's incorrect — domestic-RC sellers book no VAT at all (they report only beskattningsunderlag on ruta 41), so 2614 only sees buyer-side entries. Restore ruta 30. Also extend the check to import rutor 60/61/62 (non-EU import VAT declared via momsdeklaration since 2015 — 2615/2625/2635). Same mechanic: importer books output VAT on these rutor and deducts the input side via ruta 48. SaaS-from-AWS / OpenAI / Vercel companies hit this path; without including 60/61/62 the blocker would silently miss their misbookings. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): expose ruta 60/61/62 (import VAT) on the local VatReportResult The vat-close-check fix referenced vatReport.rutor.ruta60/61/62 but the MCP server's local VatReportResult type only carries ruta 05-49. Build broke on tsc. Extend the MCP server's slim VAT report to also project import VAT — 2615 → ruta 60 (25%), 2625 → ruta 61 (12%), 2635 → ruta 62 (6%) — and fold those into ruta 49 (att betala/återfå). Mirrors the BAS-to-Ruta mapping in lib/reports/vat-declaration.ts. Output schema and required list updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(onboarding)/select-company/page.tsx | 16 +- .../__tests__/audit-package.test.ts | 127 + .../__tests__/auto-match-period.test.ts | 148 ++ ...create-supplier-invoice-from-inbox.test.ts | 230 ++ .../__tests__/query-journal.test.ts | 146 ++ .../__tests__/vat-close-check.test.ts | 73 + .../__tests__/year-end-readiness.test.ts | 178 ++ extensions/general/mcp-server/server.ts | 1283 +++++++++- .../tic/__tests__/bankid-complete.test.ts | 26 +- .../__tests__/bankid-enrichment.pg.test.ts | 72 + extensions/general/tic/index.ts | 44 +- lib/auth/api-keys.ts | 6 + .../__tests__/delete-last-voucher.pg.test.ts | 47 + .../fiscal-period-start-day.pg.test.ts | 20 +- lib/pending-operations/risk-tiers.ts | 5 + scripts/seed-demo-account.ts | 2207 +++++++++++++++++ ...100_fiscal_periods_exclude_per_company.sql | 19 + ...20260506160000_bankid_enrichment_table.sql | 28 + 18 files changed, 4629 insertions(+), 46 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/audit-package.test.ts create mode 100644 extensions/general/mcp-server/__tests__/auto-match-period.test.ts create mode 100644 extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts create mode 100644 extensions/general/mcp-server/__tests__/query-journal.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-close-check.test.ts create mode 100644 extensions/general/mcp-server/__tests__/year-end-readiness.test.ts create mode 100644 extensions/general/tic/__tests__/bankid-enrichment.pg.test.ts create mode 100644 scripts/seed-demo-account.ts create mode 100644 supabase/migrations/20260506140100_fiscal_periods_exclude_per_company.sql create mode 100644 supabase/migrations/20260506160000_bankid_enrichment_table.sql diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx index a6e46e50..15a2247d 100644 --- a/app/(onboarding)/select-company/page.tsx +++ b/app/(onboarding)/select-company/page.tsx @@ -92,18 +92,18 @@ export default async function SelectCompanyPage() { .single() const firstName = profile?.full_name?.split(' ')[0] ?? null - // TIC enrichment (SPAR + CompanyRoles). + // BankID enrichment (CompanyRoles from Bolagsverket via TIC). Stored + // user-keyed in `bankid_enrichment` because it lands before company + // selection — see fetchAndStoreEnrichment in the tic extension. const { data: enrichmentRow } = await supabase - .from('extension_data') - .select('value, created_at, updated_at') + .from('bankid_enrichment') + .select('company_roles, created_at, updated_at') .eq('user_id', user.id) - .eq('extension_id', 'tic') - .eq('key', 'bankid_enrichment') .maybeSingle() - const enrichmentValue = enrichmentRow?.value as { - companyRoles?: EnrichmentCompanyRole[] - } | null + const enrichmentValue = enrichmentRow + ? { companyRoles: enrichmentRow.company_roles as EnrichmentCompanyRole[] } + : null // "Currently a director" = no position end date. We deliberately do NOT // also require companyStatus === 'Aktivt': real TIC payloads have been diff --git a/extensions/general/mcp-server/__tests__/audit-package.test.ts b/extensions/general/mcp-server/__tests__/audit-package.test.ts new file mode 100644 index 00000000..78093477 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/audit-package.test.ts @@ -0,0 +1,127 @@ +/** + * Unit tests for gnubok_audit_package. + * + * Verifies registration, scope mapping, the estimate-only path, and the + * size-limit guard. The full archive generation is exercised by + * lib/reports/full-archive-export tests. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +vi.mock('@/lib/reports/full-archive-export', () => ({ + generateFullArchive: vi.fn(), + estimateArchiveSize: vi.fn(), +})) + +import { + generateFullArchive, + estimateArchiveSize, +} from '@/lib/reports/full-archive-export' + +describe('gnubok_audit_package — registration', () => { + it('is registered', () => { + const tool = tools.find((t) => t.name === 'gnubok_audit_package') + expect(tool).toBeDefined() + expect(tool?.annotations.idempotentHint).toBe(true) + }) + + it('requires fiscal_period_id', () => { + const tool = tools.find((t) => t.name === 'gnubok_audit_package')! + const schema = tool.inputSchema as { required?: string[] } + expect(schema.required).toContain('fiscal_period_id') + }) + + it('is mapped to reports:read scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_audit_package).toBe('reports:read') + }) +}) + +function makePeriodMock(period: Record | null) { + return { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: period, + error: period ? null : { message: 'not found' }, + }), + }), + }), + }), + }), + storage: { + from: vi.fn(), + }, + } as never +} + +describe('gnubok_audit_package — execute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('estimate_only=true returns size estimate without uploading', async () => { + vi.mocked(estimateArchiveSize).mockResolvedValue({ + total_bytes: 5 * 1024 * 1024, + breakdown: {} as never, + } as never) + + const tool = tools.find((t) => t.name === 'gnubok_audit_package')! + const supabase = makePeriodMock({ + id: 'p1', name: '2026', + period_start: '2026-01-01', period_end: '2026-12-31', + }) + + const result = (await tool.execute( + { fiscal_period_id: 'p1', estimate_only: true }, + 'company-1', + 'user-1', + supabase, + )) as { + estimate_only: boolean + download_url: string | null + size_bytes: number + within_limit: boolean + } + + expect(result.estimate_only).toBe(true) + expect(result.download_url).toBeNull() + expect(result.size_bytes).toBe(5 * 1024 * 1024) + expect(result.within_limit).toBe(true) + expect(generateFullArchive).not.toHaveBeenCalled() + }) + + it('throws when archive would exceed size limit and include_documents=true', async () => { + vi.mocked(estimateArchiveSize).mockResolvedValue({ + total_bytes: 100 * 1024 * 1024, // > 80 MB limit + breakdown: {} as never, + } as never) + + const tool = tools.find((t) => t.name === 'gnubok_audit_package')! + const supabase = makePeriodMock({ + id: 'p1', name: '2026', + period_start: '2026-01-01', period_end: '2026-12-31', + }) + + await expect( + tool.execute( + { fiscal_period_id: 'p1' }, + 'company-1', 'user-1', supabase, + ), + ).rejects.toThrow(/exceed.*MB/) + }) + + it('throws when fiscal period is not found', async () => { + const tool = tools.find((t) => t.name === 'gnubok_audit_package')! + const supabase = makePeriodMock(null) + + await expect( + tool.execute( + { fiscal_period_id: 'nonexistent' }, + 'company-1', 'user-1', supabase, + ), + ).rejects.toThrow(/Fiscal period not found/) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/auto-match-period.test.ts b/extensions/general/mcp-server/__tests__/auto-match-period.test.ts new file mode 100644 index 00000000..79ff6bbf --- /dev/null +++ b/extensions/general/mcp-server/__tests__/auto-match-period.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for gnubok_auto_match_period. + * + * Verifies registration, dry-run preview shape, confidence threshold filtering, + * and the no-match counters. Per-item staging fault isolation is covered by + * the existing stagePendingOperation tests. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +vi.mock('@/lib/invoices/invoice-matching', () => ({ + findMatchingInvoices: vi.fn(), +})) + +import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' + +describe('gnubok_auto_match_period — registration', () => { + it('is registered', () => { + const tool = tools.find((t) => t.name === 'gnubok_auto_match_period') + expect(tool).toBeDefined() + // Stages writes when dry_run=false, so not read-only + expect(tool?.annotations.readOnlyHint).toBe(false) + expect(tool?.annotations.destructiveHint).toBe(false) + }) + + it('requires date_from and date_to', () => { + const tool = tools.find((t) => t.name === 'gnubok_auto_match_period')! + const schema = tool.inputSchema as { required?: string[] } + expect(schema.required).toContain('date_from') + expect(schema.required).toContain('date_to') + }) + + it('is mapped to transactions:write scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_auto_match_period).toBe('transactions:write') + }) +}) + +/** + * Build a mock that returns a fixed transactions array on the + * .from('transactions').select(...).eq(...).gte(...).lte(...).gt(...).is(...).is(...).order(...).limit(...) + * call chain. + */ +function makeTxMock(transactions: unknown[]) { + const result = { data: transactions, error: null } + const buildChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return () => buildChain() + }, + }, + ) + return { + from: vi.fn().mockImplementation(() => buildChain()), + } as never +} + +describe('gnubok_auto_match_period — dry run', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns proposals at and above the confidence threshold, classifies the rest', async () => { + const txs = [ + { id: 't1', date: '2026-03-01', amount: 1000, currency: 'SEK', description: 'Pay 1', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + { id: 't2', date: '2026-03-02', amount: 500, currency: 'SEK', description: 'Pay 2', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + { id: 't3', date: '2026-03-03', amount: 200, currency: 'SEK', description: 'Pay 3', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + ] + const supabase = makeTxMock(txs) + + vi.mocked(findMatchingInvoices) + // t1: high confidence — should propose + .mockResolvedValueOnce([ + { invoice: { id: 'i1', invoice_number: 'INV-1', total: 1000, customer: { name: 'Acme' } } as never, confidence: 0.95, matchReason: 'Exakt belopp + kund' }, + ]) + // t2: below threshold (0.7 < 0.9) + .mockResolvedValueOnce([ + { invoice: { id: 'i2', invoice_number: 'INV-2', total: 500, customer: { name: 'Foo' } } as never, confidence: 0.7, matchReason: 'Belopp matchar' }, + ]) + // t3: no match + .mockResolvedValueOnce([]) + + const tool = tools.find((t) => t.name === 'gnubok_auto_match_period')! + const result = (await tool.execute( + { + date_from: '2026-03-01', + date_to: '2026-03-31', + confidence_threshold: 0.9, + dry_run: true, + }, + 'company-1', + 'user-1', + supabase, + )) as { + dry_run: boolean + scanned_transactions: number + proposed_matches: number + below_threshold: number + no_match_found: number + staged_count: number + proposals: { decision: string; transaction_id: string; confidence: number }[] + } + + expect(result.dry_run).toBe(true) + expect(result.scanned_transactions).toBe(3) + expect(result.proposed_matches).toBe(1) + expect(result.below_threshold).toBe(1) + expect(result.no_match_found).toBe(1) + expect(result.staged_count).toBe(0) + + const decisions = result.proposals.map((p) => p.decision) + expect(decisions).toContain('propose') + expect(decisions).toContain('below_threshold') + }) + + it('truncates when more transactions match than max_transactions', async () => { + // Return 3 transactions when max_transactions=2 → truncated should be true. + // The tool fetches max_transactions+1 to detect truncation. + const txs = [ + { id: 't1', date: '2026-03-01', amount: 100, currency: 'SEK', description: '', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + { id: 't2', date: '2026-03-02', amount: 100, currency: 'SEK', description: '', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + { id: 't3', date: '2026-03-03', amount: 100, currency: 'SEK', description: '', merchant_name: null, reference: null, journal_entry_id: null, invoice_id: null }, + ] + const supabase = makeTxMock(txs) + vi.mocked(findMatchingInvoices).mockResolvedValue([]) + + const tool = tools.find((t) => t.name === 'gnubok_auto_match_period')! + const result = (await tool.execute( + { + date_from: '2026-03-01', + date_to: '2026-03-31', + max_transactions: 2, + dry_run: true, + }, + 'company-1', + 'user-1', + supabase, + )) as { truncated: boolean; scanned_transactions: number } + + expect(result.truncated).toBe(true) + expect(result.scanned_transactions).toBe(2) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts new file mode 100644 index 00000000..39882051 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -0,0 +1,230 @@ +/** + * Unit tests for gnubok_create_supplier_invoice_from_inbox. + * + * Verifies registration, scope, supplier-resolution branches, dry_run preview, + * already-converted guard, and the missing-extraction error. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' +import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers' + +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: vi.fn().mockResolvedValue(11.5), + convertToSEK: vi.fn(), +})) + +describe('gnubok_create_supplier_invoice_from_inbox — registration', () => { + it('is registered with idempotent + non-read-only annotations', () => { + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox') + expect(tool).toBeDefined() + expect(tool?.annotations.readOnlyHint).toBe(false) + expect(tool?.annotations.idempotentHint).toBe(true) + expect(tool?.annotations.destructiveHint).toBe(false) + }) + + it('requires inbox_item_id', () => { + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + const schema = tool.inputSchema as { required?: string[] } + expect(schema.required).toContain('inbox_item_id') + }) + + it('is mapped to suppliers:write scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_create_supplier_invoice_from_inbox).toBe('suppliers:write') + }) + + it('is classified as medium risk', () => { + expect(OPERATION_RISK_TIERS.create_supplier_invoice_from_inbox).toBe('medium') + }) +}) + +/** + * Build a supabase mock that: + * - returns the given inbox row from .from('invoice_inbox_items').select(...).eq(...).eq(...).single() + * - returns the given supplier row from .from('suppliers') lookups + * - resolves the pending_operations insert + */ +function makeMock(opts: { + inbox?: Record | null + supplierByOrg?: Record | null + supplierByName?: Record | null + pendingInsert?: Record +}) { + const inboxResult = { data: opts.inbox ?? null, error: opts.inbox ? null : { message: 'not found' } } + const supplierByOrgResult = { data: opts.supplierByOrg ?? null, error: null } + const supplierByNameResult = { data: opts.supplierByName ?? null, error: null } + const insertResult = { data: opts.pendingInsert ?? { id: 'op-1' }, error: null } + + // suppliers lookups distinguish by query method: org_number → .eq() chain ending in maybeSingle() + // name → .ilike() chain ending in maybeSingle(). + // We stub by tracking the most recent .eq vs .ilike call. Simpler: return + // org-result first, name-result second (the tool falls through). + let supplierLookupCall = 0 + const supplierChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'maybeSingle') { + return () => { + supplierLookupCall++ + return Promise.resolve(supplierLookupCall === 1 ? supplierByOrgResult : supplierByNameResult) + } + } + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(supplierByOrgResult) + } + return () => supplierChain() + }, + }, + ) + + const inboxChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'single') return () => Promise.resolve(inboxResult) + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(inboxResult) + return () => inboxChain() + }, + }, + ) + + const pendingChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'single') return () => Promise.resolve(insertResult) + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(insertResult) + return () => pendingChain() + }, + }, + ) + + return { + from: vi.fn().mockImplementation((table: string) => { + if (table === 'invoice_inbox_items') return inboxChain() + if (table === 'suppliers') return supplierChain() + if (table === 'pending_operations') return pendingChain() + return inboxChain() + }), + } as never +} + +const baseExtracted = { + supplier: { name: 'Acme AB', organizationNumber: '5566778899' }, + invoice: { invoiceNumber: 'INV-100', invoiceDate: '2026-03-15', dueDate: '2026-04-14', currency: 'SEK' }, + totals: { subtotal: 1000, vat: 250, total: 1250 }, + lineItems: [ + { description: 'Konsulttimmar', quantity: 10, unit_price: 100, line_total: 1000, vat_rate: 25, vat_amount: 250 }, + ], +} + +describe('gnubok_create_supplier_invoice_from_inbox — execute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('dry_run returns preview without inserting pending_operations', async () => { + const supabase = makeMock({ + inbox: { + id: 'inbox-1', + status: 'received', + extracted_data: baseExtracted, + matched_supplier_id: 'supplier-1', + created_supplier_invoice_id: null, + document_id: 'doc-1', + }, + }) + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + const result = (await tool.execute( + { inbox_item_id: 'inbox-1', dry_run: true }, + 'company-1', + 'user-1', + supabase, + )) as { dry_run?: boolean; staged: boolean; preview: Record } + + expect(result.dry_run).toBe(true) + expect(result.staged).toBe(false) + expect(result.preview.supplier_id).toBe('supplier-1') + expect(result.preview.supplier_resolution).toBe('matched') + expect(result.preview.total).toBe(1250) + }) + + it('falls through to org_number lookup when no matched supplier', async () => { + const supabase = makeMock({ + inbox: { + id: 'inbox-2', + status: 'received', + extracted_data: baseExtracted, + matched_supplier_id: null, + created_supplier_invoice_id: null, + document_id: 'doc-2', + }, + supplierByOrg: { id: 'supplier-org-lookup' }, + }) + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + const result = (await tool.execute( + { inbox_item_id: 'inbox-2', dry_run: true }, + 'company-1', 'user-1', supabase, + )) as { preview: { supplier_resolution: string; supplier_id: string } } + + expect(result.preview.supplier_id).toBe('supplier-org-lookup') + expect(result.preview.supplier_resolution).toBe('lookup_org_number') + }) + + it('throws when inbox item already converted', async () => { + const supabase = makeMock({ + inbox: { + id: 'inbox-3', + status: 'received', + extracted_data: baseExtracted, + matched_supplier_id: 'supplier-1', + created_supplier_invoice_id: 'si-existing', + document_id: 'doc-3', + }, + }) + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + await expect( + tool.execute({ inbox_item_id: 'inbox-3' }, 'company-1', 'user-1', supabase), + ).rejects.toThrow(/already converted/) + }) + + it('throws when supplier cannot be resolved', async () => { + const supabase = makeMock({ + inbox: { + id: 'inbox-4', + status: 'received', + extracted_data: baseExtracted, + matched_supplier_id: null, + created_supplier_invoice_id: null, + document_id: 'doc-4', + }, + supplierByOrg: null, + supplierByName: null, + }) + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + await expect( + tool.execute({ inbox_item_id: 'inbox-4', dry_run: true }, 'company-1', 'user-1', supabase), + ).rejects.toThrow(/Cannot resolve supplier/) + }) + + it('throws when extracted_data is missing', async () => { + const supabase = makeMock({ + inbox: { + id: 'inbox-5', + status: 'received', + extracted_data: null, + matched_supplier_id: 'supplier-1', + created_supplier_invoice_id: null, + document_id: null, + }, + }) + const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')! + await expect( + tool.execute({ inbox_item_id: 'inbox-5' }, 'company-1', 'user-1', supabase), + ).rejects.toThrow(/no extracted_data/) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/query-journal.test.ts b/extensions/general/mcp-server/__tests__/query-journal.test.ts new file mode 100644 index 00000000..b91893a0 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/query-journal.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for gnubok_query_journal. + * + * Verifies tool registration and the post-fetch amount filter + totals + * computation. The supabase query-builder chain is exercised by the live + * MCP smoke test; here we just check the result-shape pipeline. + */ +import { describe, it, expect, vi } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +describe('gnubok_query_journal — registration', () => { + it('is registered and read-only', () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal') + expect(tool).toBeDefined() + expect(tool?.annotations.readOnlyHint).toBe(true) + expect(tool?.annotations.destructiveHint).toBe(false) + }) + + it('declares the expected output fields', () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const schema = tool.outputSchema as { required?: string[] } + expect(schema.required).toContain('lines') + expect(schema.required).toContain('totals') + expect(schema.required).toContain('total_lines') + }) + + it('is mapped to reports:read scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_query_journal).toBe('reports:read') + }) +}) + +/** + * Build a minimal supabase mock that returns a fixed line set when the chain + * is awaited. Uses a chainable proxy whose every method returns itself, with + * the terminal awaitable resolving to { data, error, count }. + */ +function makeChainMock(lines: unknown[], count: number) { + const result = { data: lines, error: null, count } + const buildChain = (): unknown => { + return new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return () => buildChain() + }, + }, + ) + } + return { + from: vi.fn().mockImplementation(() => buildChain()), + } as never +} + +describe('gnubok_query_journal — execute', () => { + it('applies amount_min filter and computes totals on the filtered set', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const lines = [ + // Line 1: large debit — should pass amount_min: 1000 + { + id: 'l1', account_number: '4010', + debit_amount: 5000, credit_amount: 0, + currency: 'SEK', line_description: 'Hyra', project: null, cost_center: null, sort_order: 0, + journal_entries: { + id: 'e1', voucher_number: 1, voucher_series: 'A', + entry_date: '2026-03-15', description: 'Marshyra', + source_type: 'supplier_invoice', status: 'posted', + }, + }, + // Line 2: small debit — should fail amount_min: 1000 + { + id: 'l2', account_number: '4010', + debit_amount: 50, credit_amount: 0, + currency: 'SEK', line_description: 'Småinköp', project: null, cost_center: null, sort_order: 0, + journal_entries: { + id: 'e2', voucher_number: 2, voucher_series: 'A', + entry_date: '2026-03-16', description: 'Reseutlägg', + source_type: 'bank_transaction', status: 'posted', + }, + }, + ] + const supabase = makeChainMock(lines, 2) + + const result = (await tool.execute( + { account_from: '4000', account_to: '4999', amount_min: 1000, limit: 100 }, + 'company-1', + 'user-1', + supabase, + )) as { + lines: { line_id: string }[] + totals: { debit: number; credit: number; net: number } + truncated: boolean + total_lines: number + returned_lines: number + } + + // amount_min: 1000 should filter out the 50-line + expect(result.returned_lines).toBe(1) + expect(result.lines[0].line_id).toBe('l1') + expect(result.totals.debit).toBe(5000) + expect(result.totals.credit).toBe(0) + expect(result.totals.net).toBe(5000) + }) + + it('caps accounts list at 50', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const supabase = makeChainMock([], 0) + const accounts = Array.from({ length: 51 }, (_, i) => String(1000 + i)) + + await expect( + tool.execute({ accounts }, 'company-1', 'user-1', supabase), + ).rejects.toThrow(/capped at 50/) + }) + + it('marks truncated=true when count exceeds returned', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const lines = [ + { + id: 'l1', account_number: '1930', + debit_amount: 100, credit_amount: 0, + currency: 'SEK', line_description: null, project: null, cost_center: null, sort_order: 0, + journal_entries: { + id: 'e1', voucher_number: 1, voucher_series: 'A', + entry_date: '2026-01-01', description: 'Inbetalning', + source_type: 'bank_transaction', status: 'posted', + }, + }, + ] + // count=999 simulates "many more matched than were returned" + const supabase = makeChainMock(lines, 999) + + const result = (await tool.execute( + { accounts: ['1930'], limit: 1 }, + 'company-1', + 'user-1', + supabase, + )) as { truncated: boolean; total_lines: number; returned_lines: number } + + expect(result.truncated).toBe(true) + expect(result.total_lines).toBe(999) + expect(result.returned_lines).toBe(1) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/vat-close-check.test.ts b/extensions/general/mcp-server/__tests__/vat-close-check.test.ts new file mode 100644 index 00000000..7a3d483a --- /dev/null +++ b/extensions/general/mcp-server/__tests__/vat-close-check.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for gnubok_vat_close_check. + * + * Covers tool registration, scope mapping, the pure Skatteverket deadline math, + * and the basic output shape. The full multi-query integration is tested via + * the manual MCP smoke test described in the plan; mocking every chained + * supabase call here would couple tests to internal query order. + */ +import { describe, it, expect } from 'vitest' +import { tools, computeMomsDeadline } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +describe('gnubok_vat_close_check', () => { + it('is registered in the tools array', () => { + const tool = tools.find((t) => t.name === 'gnubok_vat_close_check') + expect(tool).toBeDefined() + expect(tool?.annotations.readOnlyHint).toBe(true) + expect(tool?.annotations.idempotentHint).toBe(true) + expect(tool?.annotations.destructiveHint).toBe(false) + }) + + it('has the required input schema', () => { + const tool = tools.find((t) => t.name === 'gnubok_vat_close_check')! + const schema = tool.inputSchema as { required?: string[]; properties?: Record } + expect(schema.required).toEqual(['period_type', 'year', 'period']) + expect(schema.properties).toHaveProperty('period_type') + expect(schema.properties).toHaveProperty('year') + expect(schema.properties).toHaveProperty('period') + }) + + it('declares an output schema with all the intent fields', () => { + const tool = tools.find((t) => t.name === 'gnubok_vat_close_check')! + const schema = tool.outputSchema as { required?: string[] } + expect(schema.required).toContain('rutor') + expect(schema.required).toContain('payment') + expect(schema.required).toContain('blockers') + expect(schema.required).toContain('sanity') + expect(schema.required).toContain('ready_to_close') + expect(schema.required).toContain('summary') + }) + + it('is mapped to reports:read scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_vat_close_check).toBe('reports:read') + }) +}) + +describe('computeMomsDeadline', () => { + it('monthly: March 2026 → 12 April 2026', () => { + const d = computeMomsDeadline('monthly', 2026, 3) + expect(d?.date).toBe('2026-04-12') + expect(d?.label).toBe('12 april 2026') + }) + + it('monthly: December rolls into next year', () => { + const d = computeMomsDeadline('monthly', 2026, 12) + expect(d?.date).toBe('2027-01-12') + }) + + it('quarterly: Q1 2026 → 26 April 2026', () => { + const d = computeMomsDeadline('quarterly', 2026, 1) + expect(d?.date).toBe('2026-04-26') + }) + + it('quarterly: Q4 2026 → 26 January 2027', () => { + const d = computeMomsDeadline('quarterly', 2026, 4) + expect(d?.date).toBe('2027-01-26') + }) + + it('yearly: 2026 → 26 February 2027', () => { + const d = computeMomsDeadline('yearly', 2026, 1) + expect(d?.date).toBe('2027-02-26') + }) +}) diff --git a/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts b/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts new file mode 100644 index 00000000..69bb4687 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for gnubok_year_end_readiness. + * + * Covers tool registration, scope mapping, and the blocker-kind classification + * heuristic that turns the lib's flat error strings into structured agent- + * friendly entries. Full integration with validateYearEndReadiness is covered + * by lib/core/bookkeeping tests + the manual MCP smoke test. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' + +vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({ + validateYearEndReadiness: vi.fn(), + previewYearEndClosing: vi.fn(), +})) + +import { + validateYearEndReadiness, + previewYearEndClosing, +} from '@/lib/core/bookkeeping/year-end-service' + +describe('gnubok_year_end_readiness — registration', () => { + it('is registered in the tools array', () => { + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness') + expect(tool).toBeDefined() + expect(tool?.annotations.readOnlyHint).toBe(true) + expect(tool?.annotations.destructiveHint).toBe(false) + expect(tool?.annotations.idempotentHint).toBe(true) + }) + + it('requires fiscal_period_id', () => { + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')! + const schema = tool.inputSchema as { required?: string[] } + expect(schema.required).toContain('fiscal_period_id') + }) + + it('declares output schema with intent fields', () => { + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')! + const schema = tool.outputSchema as { required?: string[] } + expect(schema.required).toContain('ready') + expect(schema.required).toContain('blockers') + expect(schema.required).toContain('warnings') + expect(schema.required).toContain('summary') + }) + + it('is mapped to reports:read scope', () => { + expect(TOOL_SCOPE_MAP.gnubok_year_end_readiness).toBe('reports:read') + }) +}) + +function makeMockSupabase(period: Record | null) { + return { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: period, error: null }), + }), + }), + }), + }), + } as never +} + +describe('gnubok_year_end_readiness — execute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('classifies common error strings into structured kinds', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue({ + ready: false, + errors: [ + '3 draft journal entries must be posted or deleted before closing', + 'Unexplained voucher gap in series A: 5-7', + 'Trial balance is not balanced: debit=100, credit=200', + 'Sequence counter integrity error in series A: counter=3 but max voucher=5', + ], + warnings: ['No posted journal entries in this period'], + draftCount: 3, + voucherGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }], + unexplainedGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }], + sequenceMismatches: [{ series: 'A', sequenceCounter: 3, actualMax: 5 }], + trialBalanceBalanced: false, + }) + + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')! + const supabase = makeMockSupabase({ + id: 'period-1', + name: '2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + is_closed: false, + locked_at: null, + closing_entry_id: null, + continuity_verified: true, + }) + + const result = (await tool.execute( + { fiscal_period_id: 'period-1' }, + 'company-1', + 'user-1', + supabase, + )) as { ready: boolean; blockers: { kind: string }[]; summary: string } + + expect(result.ready).toBe(false) + const kinds = result.blockers.map((b) => b.kind) + expect(kinds).toContain('draft_entries') + expect(kinds).toContain('unexplained_voucher_gap') + expect(kinds).toContain('sequence_mismatch') + expect(kinds).toContain('trial_balance_unbalanced') + expect(result.summary).toMatch(/Inte klart/) + }) + + it('skips preview when not requested even if ready', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue({ + ready: true, + errors: [], + warnings: [], + draftCount: 0, + voucherGaps: [], + unexplainedGaps: [], + sequenceMismatches: [], + trialBalanceBalanced: true, + }) + + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')! + const supabase = makeMockSupabase({ + id: 'period-1', name: '2026', + period_start: '2026-01-01', period_end: '2026-12-31', + is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true, + }) + + const result = (await tool.execute( + { fiscal_period_id: 'period-1' }, + 'company-1', 'user-1', supabase, + )) as { ready: boolean; preview: unknown; summary: string } + + expect(result.ready).toBe(true) + expect(result.preview).toBeNull() + expect(vi.mocked(previewYearEndClosing)).not.toHaveBeenCalled() + expect(result.summary).toMatch(/Klart för bokslut/) + }) + + it('returns the preview when include_preview=true and ready', async () => { + vi.mocked(validateYearEndReadiness).mockResolvedValue({ + ready: true, + errors: [], + warnings: [], + draftCount: 0, + voucherGaps: [], + unexplainedGaps: [], + sequenceMismatches: [], + trialBalanceBalanced: true, + }) + vi.mocked(previewYearEndClosing).mockResolvedValue({ + net_result: 12345, + closing_account: '2099', + lines: [], + } as never) + + const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')! + const supabase = makeMockSupabase({ + id: 'period-1', name: '2026', + period_start: '2026-01-01', period_end: '2026-12-31', + is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true, + }) + + const result = (await tool.execute( + { fiscal_period_id: 'period-1', include_preview: true }, + 'company-1', 'user-1', supabase, + )) as { preview: { net_result?: number } | null } + + expect(result.preview).not.toBeNull() + expect(result.preview?.net_result).toBe(12345) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 11786f3f..cb56c2cc 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -41,9 +41,12 @@ import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' import { reverseEntry } from '@/lib/bookkeeping/engine' import { closePeriod, lockPeriod } from '@/lib/core/bookkeeping/period-service' +import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { generateSIEExport } from '@/lib/reports/sie-export' +import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { getSuggestedCategories } from '@/lib/transactions/category-suggestions' import { renderToBuffer } from '@react-pdf/renderer' @@ -523,10 +526,13 @@ const VAT_REPORT_OUTPUT_SCHEMA = { ruta48: { type: 'number', description: 'Total input VAT (2641 + 2645 + 2647)' }, ruta49: { type: 'number', - description: 'VAT to pay (positive) or refund (negative) = (10+11+12+30+31+32) − 48', + description: 'VAT to pay (positive) or refund (negative) = (10+11+12+30+31+32+60+61+62) − 48', }, + ruta60: { type: 'number', description: 'Import VAT 25 % (account 2615) — non-EU import declared via momsdeklaration' }, + ruta61: { type: 'number', description: 'Import VAT 12 % (account 2625)' }, + ruta62: { type: 'number', description: 'Import VAT 6 % (account 2635)' }, }, - required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49'], + required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49', 'ruta60', 'ruta61', 'ruta62'], }, summary: { type: 'string', description: 'One-line Swedish summary string (att betala / att få tillbaka / noll)' }, warnings: { @@ -597,6 +603,10 @@ export interface VatReportResult { ruta30: number; ruta31: number; ruta32: number ruta35: number; ruta39: number; ruta40: number ruta48: number; ruta49: number + // Import VAT (post-2015 momsdeklaration path, accounts 2615/2625/2635). + // Buyer/importer self-assesses output VAT here and deducts the matching + // input via ruta 48 — same mechanic as ruta 30/31/32. + ruta60: number; ruta61: number; ruta62: number } summary: string warnings: string[] @@ -675,11 +685,17 @@ export async function computeVatReport( const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU) const ruta39 = creditBalance('3308') const ruta40 = creditBalance('3305') + // Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the + // importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the + // matching deductible input to 2645 (rolls into ruta 48 below). + const ruta60 = creditBalance('2615') + const ruta61 = creditBalance('2625') + const ruta62 = creditBalance('2635') const calculatedInput2645 = debitBalance('2645') const calculatedInput2647 = debitBalance('2647') const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647 const ruta49 = Math.round( - (ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 - ruta48) * 100 + (ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100 ) / 100 const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', @@ -723,6 +739,9 @@ export async function computeVatReport( ruta40: Math.abs(ruta40), ruta48: Math.abs(ruta48), ruta49, + ruta60: Math.abs(ruta60), + ruta61: Math.abs(ruta61), + ruta62: Math.abs(ruta62), }, summary: ruta49 > 0 ? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr` @@ -733,6 +752,362 @@ export async function computeVatReport( } } +// ── VAT close check (composes VAT report + blocker scans + sanity ratios) ── +// +// Intent-shaped tool: answers "can I close VAT for this period?" in one call. +// Replaces the 5–7 chained tool calls (vat_report + uncategorized + supplier +// invoices + reconciliation + voucher gaps + prior-period compare) the agent +// would otherwise need to assemble the same answer. + +interface VatCloseBlocker { + kind: + | 'uncategorized_transactions' + | 'unapproved_supplier_invoices' + | 'bank_unreconciled' + | 'missing_high_value_receipts' + | 'reverse_charge_input_missing' + severity: 'high' | 'medium' | 'low' + count: number + message: string + hint: string +} + +interface VatCloseSanityAnomaly { + kind: 'output_vat_ratio_drift' | 'input_vat_ratio_drift' | 'revenue_drop' | 'revenue_spike' + rate?: '25' | '12' | '6' + current: number + previous: number + delta_pct: number + message: string +} + +interface VatCloseCheckResult { + period: VatReportResult['period'] + period_label: string + rutor: VatReportResult['rutor'] + payment: { + net_due: number + direction: 'pay' | 'refund' | 'zero' + deadline: string | null + deadline_label: string | null + moms_period: 'monthly' | 'quarterly' | 'yearly' | null + } + blockers: VatCloseBlocker[] + sanity: { + anomalies: VatCloseSanityAnomaly[] + ratios: { + output_vat_ratio_25: number // ruta10 / domestic 25% revenue + output_vat_ratio_12: number + output_vat_ratio_6: number + previous_period_compared: boolean + } + } + ready_to_close: boolean + summary: string +} + +/** Compute the Skatteverket momsdeklaration deadline for a period. + * - monthly: due on the 12th of (period-end-month + 1) + * - quarterly: 26th of the month after quarter-end (Q4 → 26 Jan next year) + * - yearly: 26 Feb of next year + */ +export function computeMomsDeadline( + periodType: 'monthly' | 'quarterly' | 'yearly', + year: number, + period: number +): { date: string; label: string } | null { + if (periodType === 'monthly') { + // period 1-12; deadline = 12th of next month + const deadlineMonth = period === 12 ? 1 : period + 1 + const deadlineYear = period === 12 ? year + 1 : year + return { + date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`, + label: `12 ${monthName(deadlineMonth)} ${deadlineYear}`, + } + } + if (periodType === 'quarterly') { + // Q1→26 apr, Q2→26 jul, Q3→26 okt, Q4→26 jan next year + const monthByQuarter: Record = { + 1: { m: 4, yOffset: 0 }, + 2: { m: 7, yOffset: 0 }, + 3: { m: 10, yOffset: 0 }, + 4: { m: 1, yOffset: 1 }, + } + const cfg = monthByQuarter[period] + if (!cfg) return null + return { + date: `${year + cfg.yOffset}-${String(cfg.m).padStart(2, '0')}-26`, + label: `26 ${monthName(cfg.m)} ${year + cfg.yOffset}`, + } + } + if (periodType === 'yearly') { + return { + date: `${year + 1}-02-26`, + label: `26 februari ${year + 1}`, + } + } + return null +} + +function monthName(m: number): string { + return ['januari', 'februari', 'mars', 'april', 'maj', 'juni', + 'juli', 'augusti', 'september', 'oktober', 'november', 'december'][m - 1] ?? '' +} + +export async function computeVatCloseCheck( + args: Record, + companyId: string, + supabase: SupabaseClient +): Promise { + // 1) VAT report (validates inputs + gives us figures + period dates) + const vatReport = await computeVatReport(args, companyId, supabase) + const { start, end, type: periodType, year, period } = vatReport.period + + // 2) Company settings — moms_period drives deadline labelling + const { data: settings } = await supabase + .from('company_settings') + .select('moms_period') + .eq('company_id', companyId) + .single() + const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null + + // 3) Deadline — based on the *requested* period type, not company setting, + // so the model gets the right deadline even when querying ad-hoc periods. + const deadline = computeMomsDeadline( + periodType as 'monthly' | 'quarterly' | 'yearly', + Number(year), + Number(period) + ) + + // 4) Blocker scans — run in parallel + const [uncategorizedRes, unapprovedRes, reconRes, missingReceiptsRes] = await Promise.all([ + supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .gte('date', start).lte('date', end) + .is('journal_entry_id', null), + supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'registered') + .gte('invoice_date', start).lte('invoice_date', end), + getReconciliationStatus(supabase, companyId, start, end), + // Missing receipts: posted journal entries in period whose gross amount + // (sum of debits, equal to sum of credits in a balanced entry) is ≥ + // 4 000 SEK and that have no document_attachments. Scoped to entries + // originating from bank transactions / supplier invoices / receipts + // (where a receipt is legally expected) — skips invoice-payment + // entries, year-end entries, etc. + // + // The 4 000 SEK threshold from ML 17 kap 26–28 § (förenklad faktura) is + // expressed inclusive of moms, so we deliberately compare against the + // gross. Sum-of-debits equals the gross for ordinary purchase entries + // (expense + ingående moms + AP/bank). For EU acquisitions and domestic + // reverse-charge buyer entries the calculated VAT lines inflate the + // sum, which can pull a sub-threshold purchase above 4 000 — that's a + // false positive in favour of asking the user for the receipt, which + // is the safe direction. + (async () => { + const { data: candidates } = await supabase + .from('journal_entries') + .select( + 'id, source_type, document_attachments(id), journal_entry_lines(debit_amount)' + ) + .eq('company_id', companyId) + .in('source_type', ['bank_transaction', 'supplier_invoice', 'receipt']) + .in('status', ['posted']) + .gte('entry_date', start).lte('entry_date', end) + const missing = (candidates ?? []).filter((e) => { + const lines = (e.journal_entry_lines ?? []) as { debit_amount: number | string }[] + const gross = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) + const docs = e.document_attachments as unknown[] | null + return gross >= 4000 && (!docs || docs.length === 0) + }) + return missing.length + })(), + ]) + + const blockers: VatCloseBlocker[] = [] + const uncategorizedCount = uncategorizedRes.count ?? 0 + if (uncategorizedCount > 0) { + blockers.push({ + kind: 'uncategorized_transactions', + severity: 'high', + count: uncategorizedCount, + message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`, + hint: 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period.', + }) + } + const unapprovedCount = unapprovedRes.count ?? 0 + if (unapprovedCount > 0) { + blockers.push({ + kind: 'unapproved_supplier_invoices', + severity: 'high', + count: unapprovedCount, + message: `${unapprovedCount} oattesterade leverantörsfakturor i perioden`, + hint: 'Attestera via gnubok_approve_supplier_invoice — ingående moms (ruta 48) påverkas.', + }) + } + if (!reconRes.is_reconciled) { + blockers.push({ + kind: 'bank_unreconciled', + severity: Math.abs(reconRes.difference) > 100 ? 'high' : 'medium', + count: reconRes.unmatched_transaction_count + reconRes.unmatched_gl_line_count, + message: `Bankavstämning visar differens ${reconRes.difference.toFixed(2)} kr (${reconRes.unmatched_transaction_count} omatchade banktransaktioner, ${reconRes.unmatched_gl_line_count} omatchade huvudbokslinjer på 1930)`, + hint: 'Granska via gnubok_get_reconciliation_status och matcha — moms beräknas från huvudboken så differenser döljer fel.', + }) + } + const missingReceipts = missingReceiptsRes + if (missingReceipts > 0) { + blockers.push({ + kind: 'missing_high_value_receipts', + severity: 'medium', + count: missingReceipts, + message: `${missingReceipts} bokföringsposter över 4 000 kr saknar bifogat verifikat`, + hint: 'BFL 5 kap 6§: varje affärshändelse måste ha verifikat. Använd gnubok_list_unmatched_documents för att para ihop.', + }) + } + // Reverse-charge / import sanity: rutor 30/31/32 are the buyer's calculated + // utgående moms on reverse-charge purchases (domestic byggtjänster & + // electronics → 2614 → ruta 30; EU acquisitions of goods → 2624 → ruta 31; + // EU services → 2634 → ruta 32). Rutor 60/61/62 are the importer's + // calculated utgående moms on non-EU imports declared via momsdeklaration + // (since 2015 — 2615/2625/2635). All five carry a corresponding ingående + // moms entry that lands in ruta 48 (2645 utlandet RC, 2647 domestic RC). + // If any of these output rutor are > 0 but ruta 48 is 0, the buyer/importer + // booked the output side but forgot the deductible input — ML 2023:200. + const acquisitionAndImportBase = + vatReport.rutor.ruta30 + + vatReport.rutor.ruta31 + + vatReport.rutor.ruta32 + + vatReport.rutor.ruta60 + + vatReport.rutor.ruta61 + + vatReport.rutor.ruta62 + if (acquisitionAndImportBase > 0 && vatReport.rutor.ruta48 === 0) { + blockers.push({ + kind: 'reverse_charge_input_missing', + severity: 'high', + count: 1, + message: + 'Omvänd skattskyldighet eller import: utgående moms bokförd (ruta 30/31/32 eller 60/61/62) men ingen ingående moms (ruta 48)', + hint: 'ML 2023:200: både beräknad utgående moms och avdragsgill ingående moms ska bokföras (2645 utlandet, 2647 inhemskt).', + }) + } + + // 5) Sanity ratios — current period output VAT to revenue per rate, vs prior period + const ratios = { + output_vat_ratio_25: vatReport.rutor.ruta05 > 0 + ? Math.round((vatReport.rutor.ruta10 / vatReport.rutor.ruta05) * 10000) / 100 + : 0, + output_vat_ratio_12: 0, // no per-rate revenue split available from VAT report + output_vat_ratio_6: 0, + previous_period_compared: false, + } + const anomalies: VatCloseSanityAnomaly[] = [] + + // Compare to previous same-length period + const prevArgs = previousPeriodArgs(periodType as 'monthly' | 'quarterly' | 'yearly', Number(year), Number(period)) + if (prevArgs) { + try { + const prev = await computeVatReport(prevArgs, companyId, supabase) + ratios.previous_period_compared = true + // Output VAT ratio 25% drift + if (vatReport.rutor.ruta05 > 0 && prev.rutor.ruta05 > 0) { + const cur = vatReport.rutor.ruta10 / vatReport.rutor.ruta05 + const prv = prev.rutor.ruta10 / prev.rutor.ruta05 + if (prv > 0) { + const deltaPct = Math.round(((cur - prv) / prv) * 10000) / 100 + if (Math.abs(deltaPct) > 20) { + anomalies.push({ + kind: 'output_vat_ratio_drift', + rate: '25', + current: Math.round(cur * 10000) / 100, + previous: Math.round(prv * 10000) / 100, + delta_pct: deltaPct, + message: `Utgående moms 25% / försäljning ändrades ${deltaPct > 0 ? '+' : ''}${deltaPct}% jämfört med föregående period — kontrollera momssatser`, + }) + } + } + } + // Revenue spike/drop + if (prev.rutor.ruta05 > 0) { + const revDelta = Math.round(((vatReport.rutor.ruta05 - prev.rutor.ruta05) / prev.rutor.ruta05) * 10000) / 100 + if (revDelta < -50) { + anomalies.push({ + kind: 'revenue_drop', + current: vatReport.rutor.ruta05, + previous: prev.rutor.ruta05, + delta_pct: revDelta, + message: `Försäljning föll ${revDelta}% — bekräfta att alla fakturor är bokförda`, + }) + } else if (revDelta > 200) { + anomalies.push({ + kind: 'revenue_spike', + current: vatReport.rutor.ruta05, + previous: prev.rutor.ruta05, + delta_pct: revDelta, + message: `Försäljning steg ${revDelta}% — kontrollera att inget bokats två gånger`, + }) + } + } + } catch { + // Previous period unavailable — skip comparison silently + } + } + + const highBlockers = blockers.filter((b) => b.severity === 'high').length + const readyToClose = highBlockers === 0 + const netDue = vatReport.rutor.ruta49 + const direction: 'pay' | 'refund' | 'zero' = netDue > 0 ? 'pay' : netDue < 0 ? 'refund' : 'zero' + + let summary: string + if (readyToClose && anomalies.length === 0) { + summary = `Klart för stängning. ${direction === 'pay' ? `Moms att betala: ${netDue.toFixed(2)} kr` : direction === 'refund' ? `Moms att få tillbaka: ${Math.abs(netDue).toFixed(2)} kr` : 'Noll i moms'}.${deadline ? ` Inlämning senast ${deadline.label}.` : ''}` + } else if (readyToClose) { + summary = `Klart för stängning men ${anomalies.length} avvikelse(r) att granska.` + } else { + summary = `Inte klart: ${highBlockers} kritiska blockerare.` + } + + return { + period: vatReport.period, + period_label: vatReport.period_label, + rutor: vatReport.rutor, + payment: { + net_due: netDue, + direction, + deadline: deadline?.date ?? null, + deadline_label: deadline?.label ?? null, + moms_period: momsPeriod, + }, + blockers, + sanity: { anomalies, ratios }, + ready_to_close: readyToClose, + summary, + } +} + +function previousPeriodArgs( + periodType: 'monthly' | 'quarterly' | 'yearly', + year: number, + period: number +): { period_type: string; year: number; period: number } | null { + if (periodType === 'monthly') { + if (period === 1) return { period_type: 'monthly', year: year - 1, period: 12 } + return { period_type: 'monthly', year, period: period - 1 } + } + if (periodType === 'quarterly') { + if (period === 1) return { period_type: 'quarterly', year: year - 1, period: 4 } + return { period_type: 'quarterly', year, period: period - 1 } + } + if (periodType === 'yearly') { + return { period_type: 'yearly', year: year - 1, period: 1 } + } + return null +} + // ── Tools ──────────────────────────────────────────────────── export const tools: McpTool[] = [ @@ -1709,6 +2084,52 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_vat_close_check', + description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor + blocker scan (uncategorized, unapproved supplier invoices, reconciliation diff, missing receipts ≥ 4000 kr, reverse-charge mirroring) + period sanity ratios + Skatteverket deadline + ready_to_close.", + inputSchema: { + type: 'object', + properties: { + period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, + year: { type: 'number', description: 'Year (e.g. 2026)' }, + period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' }, + }, + required: ['period_type', 'year', 'period'], + }, + outputSchema: { + type: 'object', + properties: { + period: { type: 'object' }, + period_label: { type: 'string' }, + rutor: { type: 'object' }, + payment: { + type: 'object', + properties: { + net_due: { type: 'number' }, + direction: { type: 'string', enum: ['pay', 'refund', 'zero'] }, + deadline: { type: ['string', 'null'] }, + deadline_label: { type: ['string', 'null'] }, + moms_period: { type: ['string', 'null'] }, + }, + }, + blockers: { type: 'array', items: { type: 'object' } }, + sanity: { type: 'object' }, + ready_to_close: { type: 'boolean' }, + summary: { type: 'string' }, + }, + required: ['period', 'rutor', 'payment', 'blockers', 'sanity', 'ready_to_close', 'summary'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, _userId, supabase) { + return computeVatCloseCheck(args, companyId, supabase) + }, + }, + // ── KPI & Income Statement tools ───────────────────────────── { @@ -2389,6 +2810,243 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_query_journal', + description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.", + inputSchema: { + type: 'object', + properties: { + account_from: { type: 'string', description: 'Lowest account number (inclusive). E.g. "4000" with account_to "4999" → all class-4 expenses.' }, + account_to: { type: 'string', description: 'Highest account number (inclusive)' }, + accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' }, + date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive)' }, + date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive)' }, + amount_min: { type: 'number', description: 'Minimum line amount (absolute value of debit OR credit)' }, + amount_max: { type: 'number', description: 'Maximum line amount (absolute value)' }, + text: { type: 'string', description: 'Free-text search in entry description and line description' }, + voucher_series: { type: 'string', description: 'Filter by voucher series (e.g. "A")' }, + voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' }, + voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' }, + source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' }, + status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' }, + project: { type: 'string', description: 'Filter by project code' }, + cost_center: { type: 'string', description: 'Filter by cost center' }, + limit: { type: 'number', description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' }, + }, + }, + outputSchema: { + type: 'object', + properties: { + lines: { type: 'array', items: { type: 'object' } }, + truncated: { type: 'boolean', description: 'True if more matching lines exist than were returned' }, + total_lines: { type: 'number', description: 'Total lines matching ALL filters (incl. amount). When amount_min/amount_max is set this reflects the filtered set, not the wider DB-side match.' }, + returned_lines: { type: 'number' }, + amount_filter_applied_post_fetch: { type: 'boolean', description: 'True if amount_min/amount_max was applied client-side after the DB fetch.' }, + db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'Pre-amount-filter DB match count when amount_filter_applied_post_fetch is true; null otherwise.' }, + totals: { + type: 'object', + properties: { + debit: { type: 'number' }, + credit: { type: 'number' }, + net: { type: 'number', description: 'debit minus credit (positive = net debit)' }, + }, + }, + applied_filters: { type: 'object' }, + }, + required: ['lines', 'total_lines', 'returned_lines', 'totals'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, _userId, supabase) { + const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500) + const status = (args.status as string) || 'posted' + const accounts = args.accounts as string[] | undefined + const accountFrom = args.account_from as string | undefined + const accountTo = args.account_to as string | undefined + + if (accounts && accounts.length > 50) { + throw new Error('accounts list capped at 50 — use account_from/account_to for ranges') + } + + // Build the line-level query with a forced inner join on journal_entries + // so we can filter by the parent's company_id, status, date range, etc. + let query = supabase + .from('journal_entry_lines') + .select( + 'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)', + { count: 'exact' } + ) + .eq('journal_entries.company_id', companyId) + + if (status === 'all') { + query = query.in('journal_entries.status', ['posted', 'reversed']) + } else { + query = query.eq('journal_entries.status', status) + } + + if (accounts && accounts.length > 0) { + query = query.in('account_number', accounts) + } else { + if (accountFrom) query = query.gte('account_number', accountFrom) + if (accountTo) query = query.lte('account_number', accountTo) + } + + const dateFrom = args.date_from as string | undefined + const dateTo = args.date_to as string | undefined + if (dateFrom) query = query.gte('journal_entries.entry_date', dateFrom) + if (dateTo) query = query.lte('journal_entries.entry_date', dateTo) + + const voucherSeries = args.voucher_series as string | undefined + if (voucherSeries) query = query.eq('journal_entries.voucher_series', voucherSeries) + const vnFrom = args.voucher_number_from as number | undefined + const vnTo = args.voucher_number_to as number | undefined + if (typeof vnFrom === 'number') query = query.gte('journal_entries.voucher_number', vnFrom) + if (typeof vnTo === 'number') query = query.lte('journal_entries.voucher_number', vnTo) + + const sourceType = args.source_type as string | undefined + if (sourceType) query = query.eq('journal_entries.source_type', sourceType) + + const project = args.project as string | undefined + if (project) query = query.eq('project', project) + const costCenter = args.cost_center as string | undefined + if (costCenter) query = query.eq('cost_center', costCenter) + + // Free-text search across both line description and entry description. + // PostgREST `or` filter applies at the joined level when fully qualified. + const text = (args.text as string | undefined)?.trim() + if (text) { + // Escape both LIKE wildcards (`%` and `_`) so a search for "2_441" + // matches the literal string instead of "2X441". Replace `,` with a + // space because PostgREST treats it as the `or` separator. + const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_').replace(/,/g, ' ') + query = query.or( + `line_description.ilike.%${escaped}%,journal_entries.description.ilike.%${escaped}%` + ) + } + + // Order by date desc then voucher_number desc — most recent first + query = query + .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) + .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) + .order('sort_order', { ascending: true }) + .limit(limit) + + const { data, error, count } = await query + if (error) throw new Error(`Database error: ${error.message}`) + + type LineRow = { + id: string + account_number: string + debit_amount: number + credit_amount: number + currency: string | null + line_description: string | null + project: string | null + cost_center: string | null + sort_order: number + journal_entries: { + id: string + voucher_number: number + voucher_series: string + entry_date: string + description: string + source_type: string + status: string + } + } + + // Apply amount filter post-fetch — PostgREST can't OR an abs(debit) >= n + // with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking + // max(debit, credit) works. + const amountMin = args.amount_min as number | undefined + const amountMax = args.amount_max as number | undefined + const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number' + const filtered = (data ?? []).filter((row) => { + const r = row as unknown as LineRow + const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0) + if (typeof amountMin === 'number' && lineAmount < amountMin) return false + if (typeof amountMax === 'number' && lineAmount > amountMax) return false + return true + }) as unknown as LineRow[] + + // Compute totals on the fetched-and-filtered set. Note: when truncated, + // these are totals of the returned slice, not the full match. The + // truncated flag tells the agent whether to issue a narrower query. + let totalDebit = 0 + let totalCredit = 0 + const lines = filtered.map((r) => { + totalDebit += Number(r.debit_amount) || 0 + totalCredit += Number(r.credit_amount) || 0 + return { + line_id: r.id, + journal_entry_id: r.journal_entries.id, + voucher_series: r.journal_entries.voucher_series, + voucher_number: r.journal_entries.voucher_number, + entry_date: r.journal_entries.entry_date, + entry_description: r.journal_entries.description, + source_type: r.journal_entries.source_type, + status: r.journal_entries.status, + account_number: r.account_number, + debit: Number(r.debit_amount) || 0, + credit: Number(r.credit_amount) || 0, + line_description: r.line_description, + project: r.project, + cost_center: r.cost_center, + currency: r.currency, + } + }) + + // PostgREST's `count` is computed before the post-fetch amount filter, + // so when amount_min/amount_max is set it reflects the wider DB-side + // match — not the lines actually returned. Reporting that as + // `total_lines` would mislead an agent into chasing a truncated tail + // that has already been filtered out client-side. When the amount + // filter ran, anchor `total_lines` and `truncated` to the filtered + // result, and surface the pre-filter count + a flag separately so an + // agent can still tell the DB matched more (it just didn't pass the + // amount predicate). + const dbMatched = count ?? (data ?? []).length + const total_lines = amountFilterApplied ? lines.length : dbMatched + const truncated = amountFilterApplied + ? (data ?? []).length >= limit && lines.length === limit + : dbMatched > lines.length + return { + lines, + truncated, + total_lines, + returned_lines: lines.length, + amount_filter_applied_post_fetch: amountFilterApplied, + db_matched_pre_amount_filter: amountFilterApplied ? dbMatched : null, + totals: { + debit: Math.round(totalDebit * 100) / 100, + credit: Math.round(totalCredit * 100) / 100, + net: Math.round((totalDebit - totalCredit) * 100) / 100, + }, + applied_filters: { + account_from: accountFrom ?? null, + account_to: accountTo ?? null, + accounts: accounts ?? null, + date_from: dateFrom ?? null, + date_to: dateTo ?? null, + amount_min: amountMin ?? null, + amount_max: amountMax ?? null, + text: text ?? null, + voucher_series: voucherSeries ?? null, + voucher_number_from: vnFrom ?? null, + voucher_number_to: vnTo ?? null, + source_type: sourceType ?? null, + status, + project: project ?? null, + cost_center: costCenter ?? null, + }, + } + }, + }, + { name: 'gnubok_get_ar_ledger', description: 'Accounts receivable ledger (kundreskontra): outstanding customer invoices with aging.', @@ -2501,6 +3159,192 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_auto_match_period', + description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.", + inputSchema: { + type: 'object', + properties: { + date_from: { type: 'string', description: 'Period start YYYY-MM-DD' }, + date_to: { type: 'string', description: 'Period end YYYY-MM-DD' }, + confidence_threshold: { type: 'number', description: 'Minimum confidence to propose (0..1, default 0.9). Lower for more matches; raise for safety.' }, + dry_run: { type: 'boolean', description: 'If true (default), preview proposals without staging. If false, stage each above-threshold match as a pending operation.' }, + max_transactions: { type: 'number', description: 'Cap on transactions to process this call (default 100, max 500). Use multiple calls or narrower date ranges for very large periods.' }, + }, + required: ['date_from', 'date_to'], + }, + outputSchema: { + type: 'object', + properties: { + dry_run: { type: 'boolean' }, + confidence_threshold: { type: 'number' }, + scanned_transactions: { type: 'number' }, + proposed_matches: { type: 'number' }, + below_threshold: { type: 'number' }, + no_match_found: { type: 'number' }, + truncated: { type: 'boolean' }, + proposals: { type: 'array', items: { type: 'object' } }, + staged_count: { type: 'number' }, + stage_failures: { type: 'array', items: { type: 'object' } }, + }, + required: ['dry_run', 'scanned_transactions', 'proposed_matches', 'proposals'], + }, + annotations: { + readOnlyHint: false, // can stage when dry_run=false + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const dateFrom = args.date_from as string + const dateTo = args.date_to as string + if (!dateFrom || !dateTo) throw new Error('date_from and date_to are required') + + const confidenceThreshold = typeof args.confidence_threshold === 'number' + ? Math.max(0, Math.min(1, args.confidence_threshold)) + : 0.9 + const dryRun = args.dry_run !== false + const maxTransactions = Math.min(Math.max(1, Number(args.max_transactions) || 100), 500) + + // Fetch unmatched income transactions in window. We require positive + // amount because findMatchingInvoices only matches income; expenses are + // out of scope for this tool. + const { data: transactions, error: txError } = await supabase + .from('transactions') + .select('id, description, merchant_name, amount, currency, date, reference, journal_entry_id, invoice_id') + .eq('company_id', companyId) + .gte('date', dateFrom) + .lte('date', dateTo) + .gt('amount', 0) + .is('journal_entry_id', null) + .is('invoice_id', null) + .order('date', { ascending: true }) + .limit(maxTransactions + 1) + + if (txError) throw new Error(`Failed to fetch transactions: ${txError.message}`) + + const txList = (transactions ?? []).slice(0, maxTransactions) + const truncated = (transactions ?? []).length > maxTransactions + + type Proposal = { + transaction_id: string + transaction_date: string + transaction_amount: number + transaction_currency: string + transaction_description: string + invoice_id: string + invoice_number: string | null + invoice_total: number + customer_name: string | null + confidence: number + match_reason: string + decision: 'propose' | 'below_threshold' | 'no_match' + } + + const proposals: Proposal[] = [] + let belowThreshold = 0 + let noMatchFound = 0 + + for (const tx of txList) { + const matches = await findMatchingInvoices( + supabase, + companyId, + tx as never, + ) + if (matches.length === 0) { + noMatchFound++ + continue + } + const best = matches[0] + const baseProposal: Omit = { + transaction_id: tx.id as string, + transaction_date: tx.date as string, + transaction_amount: Number(tx.amount) || 0, + transaction_currency: tx.currency as string, + transaction_description: (tx.merchant_name as string) || (tx.description as string) || '', + invoice_id: best.invoice.id, + invoice_number: best.invoice.invoice_number, + invoice_total: best.invoice.total, + customer_name: (best.invoice.customer as { name?: string } | undefined)?.name ?? null, + confidence: Math.round(best.confidence * 1000) / 1000, + match_reason: best.matchReason, + } + if (best.confidence < confidenceThreshold) { + proposals.push({ ...baseProposal, decision: 'below_threshold' as const }) + belowThreshold++ + } else { + proposals.push({ ...baseProposal, decision: 'propose' as const }) + } + } + + const proposed = proposals.filter((p) => p.decision === 'propose') + + // Dry-run path: return proposals with reasoning, no side-effects + if (dryRun) { + return { + dry_run: true, + confidence_threshold: confidenceThreshold, + scanned_transactions: txList.length, + proposed_matches: proposed.length, + below_threshold: belowThreshold, + no_match_found: noMatchFound, + truncated, + proposals, + staged_count: 0, + stage_failures: [], + } + } + + // Commit path: stage each above-threshold match through pending_operations. + // Per-item failure isolation — one bad match doesn't kill the rest. + const stageFailures: { transaction_id: string; invoice_id: string; error: string }[] = [] + let stagedCount = 0 + for (const p of proposed) { + try { + await stagePendingOperation( + supabase, + companyId, + userId, + 'match_transaction_invoice', + `Matcha: ${p.transaction_description || p.transaction_id} → ${p.invoice_number}`, + { transaction_id: p.transaction_id, invoice_id: p.invoice_id }, + { + transaction_description: p.transaction_description, + transaction_amount: p.transaction_amount, + transaction_currency: p.transaction_currency, + invoice_number: p.invoice_number, + invoice_total: p.invoice_total, + customer_name: p.customer_name, + auto_match_confidence: p.confidence, + auto_match_reason: p.match_reason, + }, + actor, + ) + stagedCount++ + } catch (err) { + stageFailures.push({ + transaction_id: p.transaction_id, + invoice_id: p.invoice_id, + error: err instanceof Error ? err.message : 'Unknown stage error', + }) + } + } + + return { + dry_run: false, + confidence_threshold: confidenceThreshold, + scanned_transactions: txList.length, + proposed_matches: proposed.length, + below_threshold: belowThreshold, + no_match_found: noMatchFound, + truncated, + proposals, + staged_count: stagedCount, + stage_failures: stageFailures, + } + }, + }, + // ── Fiscal Periods ─────────────────────────────────────────── { @@ -2790,6 +3634,196 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_create_supplier_invoice_from_inbox', + description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.", + inputSchema: { + type: 'object', + properties: { + inbox_item_id: { type: 'string', description: 'UUID of the inbox item to convert' }, + supplier_id_override: { type: 'string', description: 'Force this supplier UUID instead of the matched/extracted one' }, + vat_treatment_override: { type: 'string', enum: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'], description: 'Override extracted VAT treatment' }, + due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' }, + notes: { type: 'string', description: 'Optional notes appended to the supplier invoice' }, + dry_run: { type: 'boolean', description: 'If true, return the assembled payload without staging (default false)' }, + idempotency_key: { type: 'string', description: 'UUID. Repeat calls with same key + payload return cached response.' }, + }, + required: ['inbox_item_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const inboxItemId = args.inbox_item_id as string + if (!inboxItemId) throw new Error('inbox_item_id is required') + const dryRun = args.dry_run === true + const idempotencyKey = args.idempotency_key as string | undefined + + // Fetch the inbox item with the attached source document + const { data: inbox, error: inboxErr } = await supabase + .from('invoice_inbox_items') + .select('id, status, extracted_data, matched_supplier_id, created_supplier_invoice_id, document_id') + .eq('id', inboxItemId) + .eq('company_id', companyId) + .single() + + if (inboxErr || !inbox) throw new Error('Inbox item not found') + if (inbox.created_supplier_invoice_id) { + throw new Error(`Inbox item already converted to supplier invoice ${inbox.created_supplier_invoice_id}`) + } + + const extracted = (inbox.extracted_data as Record | null) ?? null + if (!extracted) throw new Error('Inbox item has no extracted_data — re-run extraction first') + + const supplierExt = extracted.supplier as Record | undefined + const invoiceExt = extracted.invoice as Record | undefined + const totalsExt = extracted.totals as Record | undefined + const lineItemsExt = (extracted.lineItems as Array> | undefined) ?? [] + + // Resolve supplier — explicit override > matched > org_number lookup > name lookup + const supplierIdOverride = args.supplier_id_override as string | undefined + let supplierId: string | null = supplierIdOverride ?? (inbox.matched_supplier_id as string | null) ?? null + let supplierResolution: 'override' | 'matched' | 'lookup_org_number' | 'lookup_name' | 'unresolved' = + supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved' + + if (!supplierId) { + const orgNumber = supplierExt?.organizationNumber as string | undefined + const supplierName = supplierExt?.name as string | undefined + if (orgNumber) { + const { data } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .eq('org_number', orgNumber) + .maybeSingle() + if (data) { + supplierId = data.id + supplierResolution = 'lookup_org_number' + } + } + if (!supplierId && supplierName) { + const { data } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .ilike('name', supplierName) + .maybeSingle() + if (data) { + supplierId = data.id + supplierResolution = 'lookup_name' + } + } + } + + if (!supplierId) { + throw new Error( + `Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).` + ) + } + + // Assemble core invoice fields + const currency = (invoiceExt?.currency as string) || 'SEK' + const invoiceDate = (invoiceExt?.invoiceDate as string) || null + const dueDate = (args.due_date_override as string | undefined) ?? (invoiceExt?.dueDate as string | undefined) ?? null + const supplierInvoiceNumber = (invoiceExt?.invoiceNumber as string) || '' + if (!invoiceDate) throw new Error('Extracted invoice has no invoice date') + if (!supplierInvoiceNumber) throw new Error('Extracted invoice has no invoice number') + + const total = Number(totalsExt?.total) || 0 + const subtotal = Number(totalsExt?.subtotal) || 0 + const vatAmount = Number(totalsExt?.vat) || 0 + + // VAT treatment: explicit override wins, else heuristic from extracted data + const vatTreatment = (args.vat_treatment_override as string | undefined) + ?? (invoiceExt?.vatTreatment as string | undefined) + ?? 'standard_25' + + // FX: if non-SEK, fetch rate at fakturadatum (best-effort; agent can re-stage on failure) + let exchangeRate: number | null = null + if (currency !== 'SEK' && invoiceDate) { + try { + const result = await fetchExchangeRate(currency as Currency, new Date(invoiceDate)) + exchangeRate = result?.rate ?? null + } catch { + exchangeRate = null // Agent will be informed via preview; can override later + } + } + + // Translate extracted line items into the supplier_invoice_items shape. + // Default account 4000 (varuinköp/inköp) when extraction didn't pin one. + const lineItems = lineItemsExt.map((li, idx) => ({ + line_number: idx + 1, + description: (li.description as string) ?? `Position ${idx + 1}`, + quantity: Number(li.quantity) || 1, + unit: (li.unit as string) ?? 'st', + unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0, + line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0, + account_number: (li.account_number as string | undefined) ?? '4000', + vat_rate: Number(li.vat_rate ?? li.vatRate) || 0, + vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0, + })) + + const params = { + inbox_item_id: inboxItemId, + supplier_id: supplierId, + document_id: inbox.document_id, + supplier_invoice_number: supplierInvoiceNumber, + invoice_date: invoiceDate, + due_date: dueDate, + currency, + exchange_rate: exchangeRate, + vat_treatment: vatTreatment, + subtotal: Math.round(subtotal * 100) / 100, + vat_amount: Math.round(vatAmount * 100) / 100, + total: Math.round(total * 100) / 100, + notes: (args.notes as string | undefined) ?? null, + items: lineItems, + } + + const previewData = { + inbox_item_id: inboxItemId, + supplier_id: supplierId, + supplier_resolution: supplierResolution, + extracted_supplier_name: supplierExt?.name ?? null, + extracted_org_number: supplierExt?.organizationNumber ?? null, + supplier_invoice_number: supplierInvoiceNumber, + invoice_date: invoiceDate, + due_date: dueDate, + currency, + exchange_rate: exchangeRate, + exchange_rate_source: exchangeRate !== null ? 'riksbanken' : currency === 'SEK' ? 'not_applicable' : 'lookup_failed', + vat_treatment: vatTreatment, + subtotal: params.subtotal, + vat_amount: params.vat_amount, + total: params.total, + line_count: lineItems.length, + items_preview: lineItems.slice(0, 5), + will: 'register supplier invoice (status=registered), attach the inbox document, post a registration journal entry on confirm — leverantörsskuld (2440) credited and the cost/VAT split debited per the per-line VAT rules', + } + + return stagePendingOperation( + supabase, + companyId, + userId, + 'create_supplier_invoice_from_inbox', + `Leverantörsfaktura: ${supplierInvoiceNumber} (${(supplierExt?.name as string) ?? 'okänd'})`, + params, + previewData, + actor, + { + description: 'After approval, attest via gnubok_approve_supplier_invoice and pay via the bank flow.', + tool: 'gnubok_get_inbox_item', + args: { inbox_item_id: inboxItemId }, + }, + { dryRun, idempotencyKey }, + ) + }, + }, + { name: 'gnubok_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.', @@ -3547,9 +4581,252 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_audit_package', + description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal register, VAT) + receipts + audit log + voucher gaps, zipped. Returns a 1-hour signed download URL. Long-running on large datasets.", + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to package' }, + include_documents: { type: 'boolean', description: 'Include receipts/document binaries in the zip (default true)' }, + estimate_only: { type: 'boolean', description: 'Return size estimate without generating (default false)' }, + }, + required: ['fiscal_period_id'], + }, + outputSchema: { + type: 'object', + properties: { + download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL valid for 1 hour. Null when estimate_only=true.' }, + storage_path: { type: ['string', 'null'] }, + file_name: { type: 'string' }, + size_bytes: { type: 'number' }, + size_limit_bytes: { type: 'number' }, + within_limit: { type: 'boolean' }, + period: { type: 'object' }, + generated_at: { type: 'string' }, + expires_at: { type: ['string', 'null'] }, + estimate_only: { type: 'boolean' }, + }, + required: ['file_name', 'size_bytes', 'period', 'generated_at', 'estimate_only'], + }, + annotations: { + readOnlyHint: false, // produces a Storage artifact + destructiveHint: false, + idempotentHint: true, // repeat calls produce equivalent archives, fresh URL + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + const includeDocuments = args.include_documents !== false + const estimateOnly = args.estimate_only === true + const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 + + // Verify period belongs to the company + const { data: period, error: periodErr } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + if (periodErr || !period) throw new Error('Fiscal period not found') + + const generatedAt = new Date().toISOString() + + // Pre-flight size estimate — also serves the estimate-only path + const estimate = await estimateArchiveSize(supabase, companyId, 'period', fiscalPeriodId) + const sizeBytes = estimate.total_bytes + const withinLimit = sizeBytes <= SIZE_LIMIT_BYTES + + const fileName = `arkiv_${period.name.replace(/[^\w-]/g, '_')}_${fiscalPeriodId.slice(0, 8)}.zip` + + if (estimateOnly) { + return { + download_url: null, + storage_path: null, + file_name: fileName, + size_bytes: sizeBytes, + size_limit_bytes: SIZE_LIMIT_BYTES, + within_limit: withinLimit, + period: { + id: period.id, + name: period.name, + period_start: period.period_start, + period_end: period.period_end, + }, + generated_at: generatedAt, + expires_at: null, + estimate_only: true, + } + } + + if (includeDocuments && !withinLimit) { + throw new Error( + `Archive would exceed ${Math.round(SIZE_LIMIT_BYTES / 1024 / 1024)} MB (estimate: ${Math.round(sizeBytes / 1024 / 1024)} MB). Retry with include_documents=false to omit receipt binaries.` + ) + } + + // Generate the archive (long-running) + const zipBuffer = await generateFullArchive(supabase, companyId, { + scope: 'period', + period_id: fiscalPeriodId, + include_documents: includeDocuments, + }) + + // Upload to Storage under a per-user audit-packages folder + const storagePath = `${userId}/audit-packages/${Date.now()}_${fileName}` + const { error: uploadErr } = await supabase.storage + .from('documents') + .upload(storagePath, new Uint8Array(zipBuffer), { + contentType: 'application/zip', + upsert: false, + }) + if (uploadErr) throw new Error(`Failed to upload archive: ${uploadErr.message}`) + + // Sign for 1 hour + const SIGNED_URL_TTL_SECONDS = 3600 + const { data: signed, error: signErr } = await supabase.storage + .from('documents') + .createSignedUrl(storagePath, SIGNED_URL_TTL_SECONDS) + if (signErr || !signed) { + // Best-effort cleanup of the uploaded blob if signing failed + await supabase.storage.from('documents').remove([storagePath]) + throw new Error(`Failed to sign archive URL: ${signErr?.message ?? 'unknown error'}`) + } + + const expiresAt = new Date(Date.now() + SIGNED_URL_TTL_SECONDS * 1000).toISOString() + + return { + download_url: signed.signedUrl, + storage_path: storagePath, + file_name: fileName, + size_bytes: zipBuffer.byteLength, + size_limit_bytes: SIZE_LIMIT_BYTES, + within_limit: true, + period: { + id: period.id, + name: period.name, + period_start: period.period_start, + period_end: period.period_end, + }, + generated_at: generatedAt, + expires_at: expiresAt, + estimate_only: false, + } + }, + }, + // ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation, // voucher gaps, supplier-invoice lifecycle, proforma conversion ── + { + name: 'gnubok_year_end_readiness', + description: "Pre-flight before irreversible gnubok_run_year_end. Returns ready (bool) + ordered blockers (drafts, voucher gaps, sequence mismatches, unbalanced trial balance, FX revaluation needed) + warnings + optional preview of the closing entry. Use this before staging year-end.", + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to year-end' }, + include_preview: { type: 'boolean', description: 'If true, also return the would-be closing journal entry preview (default false)' }, + }, + required: ['fiscal_period_id'], + }, + outputSchema: { + type: 'object', + properties: { + period: { type: 'object' }, + ready: { type: 'boolean' }, + blockers: { type: 'array', items: { type: 'object' } }, + warnings: { type: 'array', items: { type: 'string' } }, + draft_count: { type: 'number' }, + unexplained_voucher_gap_count: { type: 'number' }, + sequence_mismatch_count: { type: 'number' }, + trial_balance_balanced: { type: 'boolean' }, + preview: { type: ['object', 'null'] }, + summary: { type: 'string' }, + }, + required: ['ready', 'blockers', 'warnings', 'summary'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const fiscalPeriodId = args.fiscal_period_id as string + const includePreview = args.include_preview === true + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + // Fetch period for context (the validate function returns errors if not found, + // but agents benefit from period metadata in the response) + const { data: period } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id, continuity_verified') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (!period) throw new Error('Fiscal period not found') + + const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId) + + // Reshape error strings into structured blockers so the agent (and any + // dashboard) can render and act on each one independently. The lib + // returns flat strings; we tag each with a `kind` heuristic for routing. + const blockers = validation.errors.map((message) => { + let kind: string = 'other' + if (/draft journal entries/i.test(message)) kind = 'draft_entries' + else if (/voucher gap/i.test(message)) kind = 'unexplained_voucher_gap' + else if (/Sequence counter integrity/i.test(message)) kind = 'sequence_mismatch' + else if (/Trial balance is not balanced/i.test(message)) kind = 'trial_balance_unbalanced' + else if (/already closed/i.test(message)) kind = 'period_already_closed' + else if (/has not yet ended/i.test(message)) kind = 'period_not_ended' + else if (/closing entry already exists/i.test(message)) kind = 'closing_entry_exists' + else if (/continuity check failed/i.test(message)) kind = 'opening_balance_continuity' + else if (/Fiscal period not found/i.test(message)) kind = 'period_not_found' + return { kind, severity: 'high' as const, message } + }) + + let preview = null + if (includePreview && validation.ready) { + try { + preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId) + } catch (err) { + // Preview is opportunistic — never fail the readiness check on it. + preview = { error: err instanceof Error ? err.message : 'Preview unavailable' } + } + } + + const summary = validation.ready + ? validation.warnings.length > 0 + ? `Klart för bokslut. ${validation.warnings.length} varning(ar) att granska.` + : 'Klart för bokslut.' + : `Inte klart: ${blockers.length} blockerare måste åtgärdas.` + + return { + period: { + id: period.id, + name: period.name, + period_start: period.period_start, + period_end: period.period_end, + is_closed: period.is_closed, + locked_at: period.locked_at, + closing_entry_id: period.closing_entry_id, + continuity_verified: period.continuity_verified, + }, + ready: validation.ready, + blockers, + warnings: validation.warnings, + draft_count: validation.draftCount, + unexplained_voucher_gap_count: validation.unexplainedGaps.length, + sequence_mismatch_count: validation.sequenceMismatches.length, + trial_balance_balanced: validation.trialBalanceBalanced, + preview, + summary, + } + }, + }, + { name: 'gnubok_run_year_end', description: 'Stage year-end closing: zero result accounts (class 3–8) into 2099, lock period, create next period, seed opening balances. High-risk, always staged.', diff --git a/extensions/general/tic/__tests__/bankid-complete.test.ts b/extensions/general/tic/__tests__/bankid-complete.test.ts index e78e4ae9..0c53a8a5 100644 --- a/extensions/general/tic/__tests__/bankid-complete.test.ts +++ b/extensions/general/tic/__tests__/bankid-complete.test.ts @@ -209,7 +209,7 @@ describe('POST /bankid/complete', () => { }) describe('enrichment — SPAR + CompanyRoles', () => { - it('requests both SPAR and CompanyRoles, fetches data, and persists only companyRoles (no PII) to extension_data', async () => { + it('requests both SPAR and CompanyRoles, fetches data, and persists only companyRoles (no PII) to bankid_enrichment', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) vi.mocked(requestEnrichment).mockResolvedValueOnce({ enrichmentId: 'enr-1', @@ -257,14 +257,14 @@ describe('POST /bankid/complete', () => { { error: null }, // bankid_identities insert OK ]) - // Intercept the extension_data upsert so we can assert the persisted shape + // Intercept the bankid_enrichment upsert so we can assert the persisted shape // contains no SPAR / personnummer / name. Other tables fall through to the // queued chain. const upsertSpy = vi.fn().mockResolvedValue({ error: null }) const origFrom = client.from as unknown as ReturnType const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown origFrom.mockImplementation((table: string) => { - if (table === 'extension_data') { + if (table === 'bankid_enrichment') { return { upsert: upsertSpy } } return queuedFrom(table) @@ -286,21 +286,19 @@ describe('POST /bankid/complete', () => { ) expect(vi.mocked(fetchEnrichmentData)).toHaveBeenCalledWith('/api/v1/enrichment/data/abc') - // Persisted blob must contain companyRoles + enrichedAtUtc only. + // Persisted row must contain company_roles + enriched_at_utc only. // SPAR (personnummer / name / address / birth date) must NOT be stored, // even when TIC returns it — those fields live in bankid_identities (encrypted). expect(upsertSpy).toHaveBeenCalledTimes(1) - const [persistedRow] = upsertSpy.mock.calls[0] as [ - { key: string; value: Record }, - ] - expect(persistedRow.key).toBe('bankid_enrichment') - expect(persistedRow.value).toEqual({ - companyRoles: expect.any(Array), - enrichedAtUtc: '2026-05-06T11:30:00Z', + const [persistedRow] = upsertSpy.mock.calls[0] as [Record] + expect(persistedRow).toEqual({ + user_id: expect.any(String), + company_roles: expect.any(Array), + enriched_at_utc: '2026-05-06T11:30:00Z', }) - expect(persistedRow.value).not.toHaveProperty('spar') - expect(persistedRow.value).not.toHaveProperty('personalNumber') - expect(persistedRow.value).not.toHaveProperty('name') + expect(persistedRow).not.toHaveProperty('spar') + expect(persistedRow).not.toHaveProperty('personalNumber') + expect(persistedRow).not.toHaveProperty('name') }) }) diff --git a/extensions/general/tic/__tests__/bankid-enrichment.pg.test.ts b/extensions/general/tic/__tests__/bankid-enrichment.pg.test.ts new file mode 100644 index 00000000..5007dbbd --- /dev/null +++ b/extensions/general/tic/__tests__/bankid-enrichment.pg.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from '@/tests/pg/setup' +import { insertAuthUser } from '@/tests/pg/fixtures' + +// RLS coverage for `bankid_enrichment` (migration 20260506160000). The table +// holds CompanyRoles fetched via TIC right after BankID auth and is keyed by +// user_id. Reads must be scoped to auth.uid(); writes are service-role only +// (no INSERT/UPDATE policy → RLS denies for authenticated). + +async function seedEnrichment(userId: string, roles: unknown[]): Promise { + await getPool().query( + `INSERT INTO public.bankid_enrichment (user_id, company_roles, enriched_at_utc) + VALUES ($1, $2::jsonb, now())`, + [userId, JSON.stringify(roles)], + ) +} + +describe('bankid_enrichment RLS', () => { + it("lets a user read their own enrichment row", async () => { + const userA = await insertAuthUser() + await seedEnrichment(userA, [{ orgNumber: '5560000001', position: 'VD' }]) + + await withUserContext(userA, async (client) => { + const { rows } = await client.query<{ user_id: string }>( + 'SELECT user_id FROM public.bankid_enrichment WHERE user_id = $1', + [userA], + ) + expect(rows).toHaveLength(1) + expect(rows[0]!.user_id).toBe(userA) + }) + }) + + it("hides another user's enrichment row", async () => { + const userA = await insertAuthUser() + const userB = await insertAuthUser() + await seedEnrichment(userA, [{ orgNumber: '5560000001' }]) + await seedEnrichment(userB, [{ orgNumber: '5560000002' }]) + + // Querying as userA must not see userB's row even with an explicit filter. + await withUserContext(userA, async (client) => { + const { rows } = await client.query( + 'SELECT user_id FROM public.bankid_enrichment WHERE user_id = $1', + [userB], + ) + expect(rows).toHaveLength(0) + + // Unfiltered SELECT must return only userA's row. + const all = await client.query<{ user_id: string }>( + 'SELECT user_id FROM public.bankid_enrichment', + ) + const seen = new Set(all.rows.map((r) => r.user_id)) + expect(seen.has(userA)).toBe(true) + expect(seen.has(userB)).toBe(false) + }) + }) + + it('denies INSERT from authenticated role (service-role only)', async () => { + const userA = await insertAuthUser() + + // The migration grants only SELECT to authenticated; writes go through + // the service role inside the TIC extension's BankID complete handler. + await withUserContext(userA, async (client) => { + await expect( + client.query( + `INSERT INTO public.bankid_enrichment (user_id, company_roles) + VALUES ($1, '[]'::jsonb)`, + [userA], + ), + ).rejects.toThrow(/row-level security/i) + }) + }) +}) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index bef60193..58e467b6 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -31,16 +31,21 @@ const log = createLogger('tic/bankid') /** * Request SPAR + CompanyRoles enrichment for a completed BankID session and - * cache the CompanyRoles slice in `extension_data` for the + * cache the CompanyRoles slice in `bankid_enrichment` for the * /select-company picker. * + * Stored in `bankid_enrichment` (user-keyed) rather than `extension_data` + * because enrichment runs before the user has a company; `extension_data` + * has been company-scoped (NOT NULL company_id) since the multi-tenant + * refactor. + * * SPAR (personnummer, address, name, birth date) is requested so TIC will * complete the enrichment, but is intentionally NOT persisted: personnummer * is already hashed + encrypted in `bankid_identities`, names live there too, - * and no UI currently consumes the address. Storing the SPAR blob in - * `extension_data.value` (a plain JSON column) would expose national-ID-level - * PII to anyone with read access. If/when address pre-fill is built, encrypt - * the relevant fields the same way `encryptPersonalNumber` does for pnr. + * and no UI currently consumes the address. Storing the SPAR blob alongside + * company roles would expose national-ID-level PII. If/when address pre-fill + * is built, encrypt the relevant fields the same way `encryptPersonalNumber` + * does for pnr. * * Non-blocking: any failure is logged and swallowed — BankID auth must still * succeed even if enrichment is down. @@ -125,19 +130,26 @@ async function fetchAndStoreEnrichment( // Persist only what consumers actually read. See block comment on // fetchAndStoreEnrichment for why SPAR + personnummer + name are excluded. - const persistedValue = { - companyRoles: enrichmentData.companyRoles ?? [], - enrichedAtUtc: enrichmentData.enrichedAtUtc, - } - - await supabase - .from('extension_data') + const { error: upsertError } = await supabase + .from('bankid_enrichment') .upsert({ user_id: userId, - extension_id: 'tic', - key: 'bankid_enrichment', - value: persistedValue, - }, { onConflict: 'user_id,extension_id,key' }) + company_roles: enrichmentData.companyRoles ?? [], + enriched_at_utc: enrichmentData.enrichedAtUtc ?? null, + }, { onConflict: 'user_id' }) + + if (upsertError) { + log.warn('enrichment upsert failed (non-blocking)', { + message: upsertError.message, + code: upsertError.code, + details: upsertError.details, + hint: upsertError.hint, + }) + } else { + log.info('enrichment persisted to bankid_enrichment', { + roleCount: enrichmentData.companyRoles?.length ?? 0, + }) + } } catch (enrichError) { log.warn('enrichment failed (non-blocking)', enrichError) } diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index d98574b5..b3554dc7 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -55,6 +55,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_get_counterparty_templates: 'transactions:read', gnubok_suggest_categories: 'transactions:read', gnubok_match_transaction_to_invoice: 'transactions:write', + gnubok_auto_match_period: 'transactions:write', // Customers gnubok_list_customers: 'customers:read', gnubok_create_customer: 'customers:write', @@ -71,11 +72,13 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_get_trial_balance: 'reports:read', gnubok_get_vat_report: 'reports:read', gnubok_vat_review_widget: 'reports:read', + gnubok_vat_close_check: 'reports:read', gnubok_get_kpi_report: 'reports:read', gnubok_get_income_statement: 'reports:read', gnubok_list_accounts: 'reports:read', gnubok_get_balance_sheet: 'reports:read', gnubok_get_general_ledger: 'reports:read', + gnubok_query_journal: 'reports:read', gnubok_get_ar_ledger: 'reports:read', gnubok_get_supplier_ledger: 'reports:read', gnubok_list_fiscal_periods: 'reports:read', @@ -99,6 +102,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_lock_period: 'bookkeeping:write', gnubok_unlock_period: 'bookkeeping:write', gnubok_run_year_end: 'bookkeeping:write', + gnubok_year_end_readiness: 'reports:read', gnubok_set_opening_balances: 'bookkeeping:write', gnubok_run_currency_revaluation: 'bookkeeping:write', gnubok_explain_voucher_gap: 'bookkeeping:write', @@ -107,10 +111,12 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_uncategorize_transaction: 'transactions:write', // SIE export (read-only) + import (write) gnubok_export_sie: 'reports:read', + gnubok_audit_package: 'reports:read', gnubok_import_sie: 'bookkeeping:write', // Supplier invoice lifecycle gnubok_approve_supplier_invoice: 'suppliers:write', gnubok_credit_supplier_invoice: 'suppliers:write', + gnubok_create_supplier_invoice_from_inbox: 'suppliers:write', // Invoice conversion + crediting gnubok_convert_invoice: 'invoices:write', gnubok_credit_invoice: 'invoices:write', diff --git a/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts b/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts index 02d1c6d9..a0fd28cc 100644 --- a/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts +++ b/lib/bookkeeping/__tests__/delete-last-voucher.pg.test.ts @@ -41,6 +41,33 @@ async function insertPostedEntryWithLines(params: { return id } +// Insert a document_attachment row already linked to a journal entry, so +// tests can exercise the bidirectional immutability trigger on the +// journal_entry_id column. +async function insertDocumentLinkedToEntry(params: { + userId: string + companyId: string + journalEntryId: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.document_attachments + (id, user_id, company_id, storage_path, file_name, sha256_hash, + journal_entry_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + params.userId, + params.companyId, + `test/${id}.pdf`, + 'receipt.pdf', + 'a'.repeat(64), + params.journalEntryId, + ], + ) + return id +} + describe('delete_last_voucher.pg — RPC + immutability trigger interaction', () => { it('deletes the last posted voucher in a series', async () => { const { userId, companyId, fiscalPeriodId } = await seedCompany() @@ -190,4 +217,24 @@ describe('delete_last_voucher.pg — RPC + immutability trigger interaction', () ), ).rejects.toThrow(/Cannot modify a reversed journal entry/i) }) + + // The bypass must remain narrow: an unauthorized direct UPDATE that clears + // journal_entry_id outside delete_last_voucher (no gnubok.allow_delete + // transaction-local flag) must still raise BFL_DOCUMENT_IMMUTABILITY. + it('blocks direct UPDATE that nulls journal_entry_id without the bypass flag', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedEntryWithLines({ + userId, companyId, fiscalPeriodId, voucherNumber: 1, + }) + const documentId = await insertDocumentLinkedToEntry({ + userId, companyId, journalEntryId: entryId, + }) + + await expect( + getPool().query( + `UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`, + [documentId], + ), + ).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/) + }) }) diff --git a/lib/import/__tests__/fiscal-period-start-day.pg.test.ts b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts index ae679fa1..478034eb 100644 --- a/lib/import/__tests__/fiscal-period-start-day.pg.test.ts +++ b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts @@ -7,6 +7,11 @@ import { seedCompany } from '@/tests/pg/fixtures' // strictly earlier period exists — so importing a company's chronologically // first fiscal year (förlängt första räkenskapsår) via SIE must succeed even // after a later period was created during onboarding. +// +// seedCompany() creates a default 2026-01-01..2026-12-31 fiscal period; the +// no_overlapping_fiscal_periods exclusion constraint (per-company since +// migration 20260506140100) means every period inserted here must avoid +// overlapping that year. The years below are chosen accordingly. describe('fiscal_periods: subsequent-period start-day trigger', () => { async function insertPeriod( companyId: string, @@ -26,11 +31,14 @@ describe('fiscal_periods: subsequent-period start-day trigger', () => { it('allows a mid-month start when no earlier period exists', async () => { const { companyId } = await seedCompany() + // The seeded 2026 period is later than this one, so this insert is the + // chronologically earliest period for the company → trigger must permit + // a mid-month start (förlängt första räkenskapsår path). const { rows } = await insertPeriod( companyId, - 'Räkenskapsår 2025', - '2025-06-15', - '2026-06-30', + 'Räkenskapsår 2024/2025', + '2024-06-15', + '2025-12-31', ) expect(rows[0]!.id).toBeTruthy() }) @@ -38,7 +46,7 @@ describe('fiscal_periods: subsequent-period start-day trigger', () => { it('allows importing an earlier mid-month period after a later day-1 period exists', async () => { const { companyId } = await seedCompany() - // Onboarding-created period (day 1, year N). + // Onboarding-created period (day 1, year N) — sits before the seeded 2026. await insertPeriod(companyId, 'Räkenskapsår 2025', '2025-01-01', '2025-12-31') // SIE import of förlängt första räkenskapsår — earlier in time, @@ -57,8 +65,10 @@ describe('fiscal_periods: subsequent-period start-day trigger', () => { await insertPeriod(companyId, 'Räkenskapsår 2024', '2024-01-01', '2024-12-31') + // Mid-month start in 2025 — strictly later than 2024 and not overlapping + // with the seeded 2026 period → only the start-day trigger should fire. await expect( - insertPeriod(companyId, 'Räkenskapsår 2025 (bad)', '2025-06-15', '2026-06-30'), + insertPeriod(companyId, 'Räkenskapsår 2025 (bad)', '2025-06-15', '2025-12-31'), ).rejects.toThrow(/Non-first fiscal period must start on the 1st of a month/) }) }) diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index d77d482f..1aefd51a 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -50,6 +50,11 @@ export const OPERATION_RISK_TIERS: Record = { uncategorize_transaction: 'medium', approve_supplier_invoice: 'high', credit_supplier_invoice: 'high', + // Create supplier invoice from inbox: stages a `registered` supplier invoice + // + its line items + document attachment. Reversible until approved (the + // approval is a separate high-risk op) but creates a leverantörsskuld row, + // so we route it through human review at medium tier. + create_supplier_invoice_from_inbox: 'medium', credit_invoice: 'high', convert_invoice: 'medium', } diff --git a/scripts/seed-demo-account.ts b/scripts/seed-demo-account.ts new file mode 100644 index 00000000..70dcd923 --- /dev/null +++ b/scripts/seed-demo-account.ts @@ -0,0 +1,2207 @@ +/** + * Seed a complete gnubok demo environment for an existing auth user. + * + * Creates two companies (Konsult AB driftbolag, Konsult Holding AB), + * a fully posted FY2025 (~+487k result, ~290 verifications, 2 voucher gaps), + * an active FY2026 (32 customer invoices in mixed states, 4 May unsent, + * Stripe payouts, supplier invoices, salary runs, an AWS inbox PDF, and + * 5 uncategorized bank transactions for demo flows). + * + * Usage: + * npx tsx scripts/seed-demo-account.ts [--force] + * + * --force wipes existing Konsult AB / Konsult Holding AB owned by the + * target user before re-seeding. Without --force the script bails if + * either company already exists for that user. + * + * External systems (Gmail / Calendar / Drive / Slack) are out of scope — + * a checklist is printed at the end for manual setup. + * + * Requires SUPABASE_SERVICE_ROLE_KEY in .env.local. + */ + +import { createClient } from '@supabase/supabase-js' +import { config as dotenv } from 'dotenv' +import { resolve } from 'node:path' + +dotenv({ path: resolve(process.cwd(), '.env.local') }) + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL +const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY +if (!SUPABASE_URL || !SERVICE_KEY) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const sb = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false }, +}) + +const args = process.argv.slice(2) +const emailArg = args.find((a) => !a.startsWith('--')) +if (!emailArg) { + console.error('Usage: npx tsx scripts/seed-demo-account.ts [--force]') + console.error('Refusing to run without an explicit target email — the script') + console.error('seeds demo data and `--force` wipes existing Konsult AB / Konsult') + console.error('Holding AB owned by the target user before re-seeding.') + process.exit(1) +} +const email: string = emailArg +const force = args.includes('--force') + +const pad = (n: number) => String(n).padStart(2, '0') +const dt = (y: number, m: number, d: number) => `${y}-${pad(m)}-${pad(d)}` +const round2 = (n: number) => Math.round(n * 100) / 100 + +type AccountMap = Record + +interface CompanyCtx { + companyId: string + userId: string + fpY: Record + accounts: AccountMap + voucher: Record +} + +async function findUser(email: string): Promise { + let page = 1 + for (;;) { + const { data, error } = await sb.auth.admin.listUsers({ page, perPage: 200 }) + if (error) throw new Error(`auth.admin.listUsers: ${error.message}`) + const u = data.users.find((x) => x.email === email) + if (u) return u.id + if (data.users.length < 200) break + page++ + } + throw new Error(`User ${email} not found in auth.users`) +} + +// Verifikationsnummer skip-list: introduces deliberate gaps that require +// explanations under BFNAR 2013:2 — used for the voucher-gap demo. +const VOUCHER_GAPS: Record> = { + 2025: new Set([123, 287]), +} + +async function wipeExisting(userId: string): Promise { + const { data: existing, error } = await sb + .from('companies') + .select('id, name') + .eq('created_by', userId) + .in('name', ['Konsult AB', 'Konsult Holding AB']) + if (error) throw error + if (!existing || existing.length === 0) return + console.log(` wiping ${existing.length} existing demo companies`) + for (const c of existing) { + await sb.from('voucher_sequences').delete().eq('company_id', c.id) + await sb.from('transactions').delete().eq('company_id', c.id) + await sb.from('invoice_payments').delete().eq('company_id', c.id) + await sb.from('invoice_items').delete().in( + 'invoice_id', + ((await sb.from('invoices').select('id').eq('company_id', c.id)).data ?? []).map((r) => r.id) + ) + await sb.from('supplier_invoice_items').delete().in( + 'supplier_invoice_id', + ( + (await sb.from('supplier_invoices').select('id').eq('company_id', c.id)).data ?? [] + ).map((r) => r.id) + ) + await sb.from('invoices').delete().eq('company_id', c.id) + await sb.from('supplier_invoices').delete().eq('company_id', c.id) + await sb.from('invoice_inbox_items').delete().eq('company_id', c.id) + await sb.from('document_attachments').delete().eq('company_id', c.id) + await sb.from('customers').delete().eq('company_id', c.id) + await sb.from('suppliers').delete().eq('company_id', c.id) + await sb.from('employees').delete().eq('company_id', c.id) + await sb.from('journal_entry_lines').delete().in( + 'journal_entry_id', + ( + (await sb.from('journal_entries').select('id').eq('company_id', c.id)).data ?? [] + ).map((r) => r.id) + ) + await sb.from('journal_entries').delete().eq('company_id', c.id) + await sb.from('account_balances').delete().eq('company_id', c.id) + await sb.from('chart_of_accounts').delete().eq('company_id', c.id) + await sb.from('fiscal_periods').delete().eq('company_id', c.id) + await sb.from('company_settings').delete().eq('company_id', c.id) + await sb.from('company_members').delete().eq('company_id', c.id) + await sb.from('companies').delete().eq('id', c.id) + } +} + +async function createCompany( + userId: string, + name: string, + orgNumber: string, + entityType: 'aktiebolag' | 'enskild_firma' +): Promise { + const { data: c, error } = await sb + .from('companies') + .insert({ + name, + org_number: orgNumber, + entity_type: entityType, + created_by: userId, + }) + .select('id') + .single() + if (error) throw new Error(`createCompany ${name}: ${error.message}`) + await sb.from('company_members').insert({ + company_id: c.id, + user_id: userId, + role: 'owner', + source: 'direct', + }) + return c.id +} + +async function setupCompany( + userId: string, + companyId: string, + settings: Record, + fiscalYears: number[] +): Promise<{ fpY: Record; accounts: AccountMap }> { + await sb.from('company_settings').insert({ + user_id: userId, + company_id: companyId, + accounting_method: 'accrual', + onboarding_complete: true, + onboarding_step: 6, + is_sandbox: false, + pays_salaries: true, + default_voucher_series: 'A', + ai_flow_enabled: false, + ai_backfill_cancel_requested: false, + ...settings, + }) + const { error: coaErr } = await sb.rpc('seed_chart_of_accounts', { + p_company_id: companyId, + p_entity_type: 'aktiebolag', + }) + if (coaErr) throw new Error(`seed_chart_of_accounts: ${coaErr.message}`) + + // The default AB seed is missing several accounts we use during the demo. + // Fill them in here so journal entry lines have a valid account_id to link + // to and reports look correct. + const extraAccounts: Array<{ + n: string + name: string + cls: number + grp: string + type: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' + nb: 'debit' | 'credit' + }> = [ + { n: '1230', name: 'Inventarier och verktyg', cls: 1, grp: '12', type: 'asset', nb: 'debit' }, + { n: '1310', name: 'Andelar i koncernforetag', cls: 1, grp: '13', type: 'asset', nb: 'debit' }, + { n: '2614', name: 'Utgaende moms omvand skattskyldighet 25%', cls: 2, grp: '26', type: 'liability', nb: 'credit' }, + { n: '2645', name: 'Beraknad ingaende moms', cls: 2, grp: '26', type: 'liability', nb: 'debit' }, + { n: '3305', name: 'Forsaljning tjanster export', cls: 3, grp: '33', type: 'revenue', nb: 'credit' }, + { n: '3308', name: 'Forsaljning tjanster EU omvand', cls: 3, grp: '33', type: 'revenue', nb: 'credit' }, + { n: '7410', name: 'Pensionsforsakringspremier', cls: 7, grp: '74', type: 'expense', nb: 'debit' }, + ] + await sb.from('chart_of_accounts').insert( + extraAccounts.map((a) => ({ + user_id: userId, + company_id: companyId, + account_number: a.n, + account_name: a.name, + account_class: a.cls, + account_group: a.grp, + account_type: a.type, + normal_balance: a.nb, + plan_type: 'k1', + is_system_account: false, + })) + ) + + const fpY: Record = {} + let prev: string | null = null + for (const y of fiscalYears) { + const { data: fp, error } = (await sb + .from('fiscal_periods') + .insert({ + user_id: userId, + company_id: companyId, + name: `Räkenskapsår ${y}`, + period_start: dt(y, 1, 1), + period_end: dt(y, 12, 31), + is_closed: false, + opening_balances_set: y === fiscalYears[0], + previous_period_id: prev, + }) + .select('id') + .single()) as { data: { id: string } | null; error: { message: string } | null } + if (error || !fp) throw new Error(`fiscal_periods ${y}: ${error?.message ?? 'no data'}`) + fpY[y] = fp.id + prev = fp.id + } + + const { data: accs, error: aErr } = await sb + .from('chart_of_accounts') + .select('id, account_number') + .eq('company_id', companyId) + if (aErr) throw aErr + const accounts: AccountMap = Object.fromEntries((accs ?? []).map((a) => [a.account_number, a.id])) + return { fpY, accounts } +} + +interface JELine { + account: string + debit?: number + credit?: number + description?: string + currency?: string + amount_in_currency?: number + exchange_rate?: number +} + +async function postEntry( + ctx: CompanyCtx, + fy: number, + date: string, + description: string, + sourceType: string, + lines: JELine[], + opts: { sourceId?: string | null; series?: string } = {} +): Promise { + const series = opts.series ?? 'A' + const totalDebit = round2(lines.reduce((s, l) => s + (l.debit ?? 0), 0)) + const totalCredit = round2(lines.reduce((s, l) => s + (l.credit ?? 0), 0)) + if (Math.abs(totalDebit - totalCredit) > 0.01) { + throw new Error( + `Unbalanced entry "${description}" on ${date}: debit ${totalDebit} vs credit ${totalCredit}` + ) + } + const fpId = ctx.fpY[fy] + if (!fpId) throw new Error(`No fiscal period for ${fy}`) + let next = (ctx.voucher[fy] ?? 0) + 1 + const gaps = VOUCHER_GAPS[fy] + while (gaps && gaps.has(next)) next++ + ctx.voucher[fy] = next + const { data: je, error } = await sb + .from('journal_entries') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + fiscal_period_id: fpId, + voucher_number: next, + voucher_series: series, + entry_date: date, + description, + source_type: sourceType, + source_id: opts.sourceId ?? null, + status: 'posted', + committed_at: new Date(date).toISOString(), + created_via: 'system', + }) + .select('id') + .single() + if (error) throw new Error(`postEntry "${description}": ${error.message}`) + + const { error: lineErr } = await sb.from('journal_entry_lines').insert( + lines.map((l, i) => ({ + journal_entry_id: je.id, + account_number: l.account, + account_id: ctx.accounts[l.account] ?? null, + debit_amount: round2(l.debit ?? 0), + credit_amount: round2(l.credit ?? 0), + currency: l.currency ?? null, + amount_in_currency: l.amount_in_currency ?? null, + exchange_rate: l.exchange_rate ?? null, + line_description: l.description ?? null, + sort_order: i, + })) + ) + if (lineErr) throw new Error(`lines for "${description}": ${lineErr.message}`) + + await sb + .from('voucher_sequences') + .upsert( + { + user_id: ctx.userId, + company_id: ctx.companyId, + fiscal_period_id: fpId, + voucher_series: series, + last_number: next, + }, + { onConflict: 'company_id,fiscal_period_id,voucher_series' } + ) + return je.id +} + +function skipVoucher(ctx: CompanyCtx, fy: number, n: number): void { + if ((ctx.voucher[fy] ?? 0) < n) { + ctx.voucher[fy] = n + } +} + +async function seedKonsultAB(userId: string): Promise { + console.log('[2] Creating Konsult AB') + const companyId = await createCompany(userId, 'Konsult AB', '5591234567', 'aktiebolag') + const { fpY, accounts } = await setupCompany( + userId, + companyId, + { + entity_type: 'aktiebolag', + company_name: 'Konsult AB', + org_number: '559123-4567', + vat_number: 'SE559123456701', + vat_registered: true, + f_skatt: true, + moms_period: 'quarterly', + fiscal_year_start_month: 1, + address_line1: 'Vasagatan 16', + postal_code: '111 20', + city: 'Stockholm', + country: 'SE', + email: 'info@konsult.se', + bank_name: 'SEB', + clearing_number: '5295', + account_number: '1234567', + bankgiro: '5295-1234', + invoice_prefix: 'F', + next_invoice_number: 1, + invoice_default_days: 30, + has_employees: true, + employee_count: 3, + sells_internationally: true, + preliminary_tax_monthly: 18000, + }, + [2025, 2026] + ) + return { companyId, userId, fpY, accounts, voucher: {} } +} + +async function seedHoldingAB(userId: string): Promise { + console.log('[2] Creating Konsult Holding AB') + const companyId = await createCompany( + userId, + 'Konsult Holding AB', + '5592345678', + 'aktiebolag' + ) + const { fpY, accounts } = await setupCompany( + userId, + companyId, + { + entity_type: 'aktiebolag', + company_name: 'Konsult Holding AB', + org_number: '559234-5678', + vat_number: 'SE559234567801', + vat_registered: true, + f_skatt: true, + moms_period: 'yearly', + fiscal_year_start_month: 1, + address_line1: 'Vasagatan 16', + postal_code: '111 20', + city: 'Stockholm', + country: 'SE', + email: 'info@konsultholding.se', + bank_name: 'Handelsbanken', + clearing_number: '6789', + account_number: '1234567', + invoice_prefix: 'H', + next_invoice_number: 1, + invoice_default_days: 30, + has_employees: false, + employee_count: 0, + sells_internationally: false, + }, + [2026] + ) + return { companyId, userId, fpY, accounts, voucher: {} } +} + +interface CustomerSeed { + name: string + customer_type: 'swedish_business' | 'eu_business' | 'non_eu_business' | 'individual' + org_number?: string + vat_number?: string + vat_number_validated?: boolean + email: string + country: string + address_line1?: string + postal_code?: string + city?: string + default_payment_terms?: number + is_international?: boolean +} + +async function seedCustomers(ctx: CompanyCtx, seeds: CustomerSeed[]): Promise> { + const rows = seeds.map((s) => ({ + user_id: ctx.userId, + company_id: ctx.companyId, + default_payment_terms: 30, + ...s, + })) + const { data, error } = await sb.from('customers').insert(rows).select('id, name') + if (error) throw new Error(`customers: ${error.message}`) + return Object.fromEntries((data ?? []).map((c) => [c.name, c.id])) +} + +interface SupplierSeed { + name: string + supplier_type: 'swedish_business' | 'eu_business' | 'non_eu_business' | 'individual' + country: string + default_currency: string + vat_number?: string + default_expense_account?: string + category?: string +} + +async function seedSuppliers(ctx: CompanyCtx, seeds: SupplierSeed[]): Promise> { + const rows = seeds.map((s) => ({ + user_id: ctx.userId, + company_id: ctx.companyId, + is_active: true, + default_payment_terms: 30, + ...s, + })) + const { data, error } = await sb.from('suppliers').insert(rows).select('id, name') + if (error) throw new Error(`suppliers: ${error.message}`) + return Object.fromEntries((data ?? []).map((s) => [s.name, s.id])) +} + +async function seedEmployees(ctx: CompanyCtx): Promise> { + const seeds = [ + { + first_name: 'Anna', + last_name: 'Andersson', + personnummer: '198506151234', + personnummer_last4: '1234', + employment_type: 'employee', + employment_start: '2025-01-01', + employment_degree: 100, + salary_type: 'monthly', + monthly_salary: 65000, + tax_table_number: 31, + tax_column: 1, + tax_municipality: 'Stockholm', + is_sidoinkomst: false, + vacation_rule: 'sammaloneregeln', + vacation_days_per_year: 25, + vacation_days_saved: 0, + semestertillagg_rate: 0.0043, + vaxa_stod_eligible: false, + is_active: true, + email: 'anna@konsult.se', + }, + { + first_name: 'Erik', + last_name: 'Ek', + personnummer: '199203105678', + personnummer_last4: '5678', + employment_type: 'employee', + employment_start: '2026-01-01', + employment_degree: 100, + salary_type: 'monthly', + monthly_salary: 52000, + tax_table_number: 31, + tax_column: 1, + tax_municipality: 'Stockholm', + is_sidoinkomst: false, + vacation_rule: 'sammaloneregeln', + vacation_days_per_year: 25, + vacation_days_saved: 0, + semestertillagg_rate: 0.0043, + vaxa_stod_eligible: false, + is_active: true, + email: 'erik@konsult.se', + }, + { + first_name: 'Johan', + last_name: 'Lind', + personnummer: '198801019012', + personnummer_last4: '9012', + employment_type: 'company_owner', + employment_start: '2026-01-01', + employment_degree: 100, + salary_type: 'monthly', + monthly_salary: 70000, + tax_table_number: 31, + tax_column: 1, + tax_municipality: 'Stockholm', + is_sidoinkomst: false, + vacation_rule: 'sammaloneregeln', + vacation_days_per_year: 25, + vacation_days_saved: 0, + semestertillagg_rate: 0.0043, + vaxa_stod_eligible: false, + is_active: true, + email: 'johan@konsult.se', + }, + ] + const rows = seeds.map((s) => ({ user_id: ctx.userId, company_id: ctx.companyId, ...s })) + const { data, error } = await sb.from('employees').insert(rows).select('id, first_name') + if (error) throw new Error(`employees: ${error.message}`) + return Object.fromEntries((data ?? []).map((e) => [e.first_name, e.id])) +} + +interface InvoiceSeed { + number: string + customerId: string + customerName: string + date: string + dueDate: string + status: 'draft' | 'sent' | 'overdue' | 'paid' | 'partially_paid' + vatTreatment: 'standard_25' | 'reverse_charge' | 'export' + vatRate: number + subtotal: number + description: string + hours?: number + unitPrice?: number + paidAmount?: number + paidAt?: string + currency?: string +} + +async function createInvoice(ctx: CompanyCtx, fy: number, inv: InvoiceSeed): Promise { + const vatAmount = round2(inv.subtotal * (inv.vatRate / 100)) + const total = round2(inv.subtotal + vatAmount) + const paidAmount = inv.paidAmount ?? (inv.status === 'paid' ? total : 0) + const remaining = round2(total - paidAmount) + const momsRuta = + inv.vatTreatment === 'standard_25' + ? '10' + : inv.vatTreatment === 'reverse_charge' + ? '39' + : inv.vatTreatment === 'export' + ? '36' + : null + const reverseChargeText = + inv.vatTreatment === 'reverse_charge' + ? 'Reverse charge — buyer is liable for VAT (Article 196 EU VAT Directive)' + : null + + const { data, error } = await sb + .from('invoices') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + customer_id: inv.customerId, + invoice_number: inv.number, + invoice_date: inv.date, + due_date: inv.dueDate, + status: inv.status, + currency: inv.currency ?? 'SEK', + subtotal: inv.subtotal, + vat_amount: vatAmount, + total, + vat_treatment: inv.vatTreatment, + vat_rate: inv.vatRate, + moms_ruta: momsRuta, + reverse_charge_text: reverseChargeText, + document_type: 'invoice', + paid_at: inv.paidAt ?? null, + paid_amount: paidAmount, + remaining_amount: remaining, + }) + .select('id') + .single() + if (error) throw new Error(`invoice ${inv.number}: ${error.message}`) + + await sb.from('invoice_items').insert({ + invoice_id: data.id, + description: inv.description, + quantity: inv.hours ?? 1, + unit: inv.hours ? 'tim' : 'st', + unit_price: inv.unitPrice ?? inv.subtotal, + line_total: inv.subtotal, + vat_rate: inv.vatRate, + vat_amount: vatAmount, + sort_order: 0, + }) + + // Booking entry: Invoice creation (DR 1510 / CR 30xx + 26xx) + const revenueAccount = + inv.vatTreatment === 'reverse_charge' + ? '3308' + : inv.vatTreatment === 'export' + ? '3305' + : '3001' + const lines: JELine[] = [ + { account: '1510', debit: total, description: `Kundfordran ${inv.customerName}` }, + { account: revenueAccount, credit: inv.subtotal, description: 'Försäljning' }, + ] + if (vatAmount > 0) { + lines.push({ + account: inv.vatRate === 25 ? '2610' : inv.vatRate === 12 ? '2611' : '2612', + credit: vatAmount, + description: `Utgående moms ${inv.vatRate}%`, + }) + } + await postEntry( + ctx, + fy, + inv.date, + `Faktura ${inv.number} — ${inv.customerName}`, + 'invoice_created', + lines, + { sourceId: data.id } + ) + + // Payment if paid or partial + if ((inv.status === 'paid' || inv.status === 'partially_paid') && paidAmount > 0 && inv.paidAt) { + const payJeId = await postEntry( + ctx, + fy, + inv.paidAt, + `Betalning faktura ${inv.number}`, + 'invoice_paid', + [ + { account: '1930', debit: paidAmount }, + { account: '1510', credit: paidAmount, description: `Reglering ${inv.customerName}` }, + ], + { sourceId: data.id } + ) + await sb.from('invoice_payments').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + invoice_id: data.id, + payment_date: inv.paidAt, + amount: paidAmount, + currency: 'SEK', + journal_entry_id: payJeId, + }) + // Bank transaction + await sb.from('transactions').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + date: inv.paidAt, + description: `Inbetalning ${inv.customerName} ${inv.number}`, + amount: paidAmount, + currency: 'SEK', + amount_sek: paidAmount, + category: 'income_services', + is_business: true, + invoice_id: data.id, + journal_entry_id: payJeId, + merchant_name: inv.customerName, + import_source: 'demo_seed', + }) + } + return data.id +} + +interface SupplierInvoiceSeed { + supplierId: string + supplierName: string + number: string + date: string + dueDate: string + receivedDate: string + subtotal: number + vatRate: number + account: string + description: string + paid: boolean + paidAt?: string + currency?: string + exchangeRate?: number + reverseCharge?: boolean + vatTreatment?: 'standard_25' | 'standard_12' | 'standard_6' | 'reverse_charge' | 'import_outside_eu' +} + +async function createSupplierInvoice( + ctx: CompanyCtx, + fy: number, + inv: SupplierInvoiceSeed, + arrivalNumber: number +): Promise { + const treatment = inv.vatTreatment ?? 'standard_25' + const reverse = inv.reverseCharge ?? treatment === 'reverse_charge' + const xr = inv.exchangeRate ?? 1 + const vatAmount = reverse ? 0 : round2(inv.subtotal * (inv.vatRate / 100)) + const total = round2(inv.subtotal + vatAmount) + const subtotalSek = round2(inv.subtotal * xr) + const vatSek = round2(vatAmount * xr) + const totalSek = round2(total * xr) + const paidAmount = inv.paid ? total : 0 + const remaining = round2(total - paidAmount) + + const { data, error } = await sb + .from('supplier_invoices') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + supplier_id: inv.supplierId, + arrival_number: arrivalNumber, + supplier_invoice_number: inv.number, + invoice_date: inv.date, + due_date: inv.dueDate, + received_date: inv.receivedDate, + status: inv.paid ? 'paid' : 'approved', + currency: inv.currency ?? 'SEK', + exchange_rate: inv.currency && inv.currency !== 'SEK' ? xr : null, + subtotal: inv.subtotal, + subtotal_sek: subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: vatSek, + total, + total_sek: totalSek, + vat_treatment: treatment, + reverse_charge: reverse, + paid_amount: paidAmount, + remaining_amount: remaining, + is_credit_note: false, + paid_at: inv.paidAt ?? null, + }) + .select('id') + .single() + if (error) throw new Error(`supplier_invoice ${inv.number}: ${error.message}`) + + await sb.from('supplier_invoice_items').insert({ + supplier_invoice_id: data.id, + sort_order: 0, + description: inv.description, + quantity: 1, + unit: 'st', + unit_price: inv.subtotal, + line_total: inv.subtotal, + account_number: inv.account, + vat_rate: inv.vatRate, + vat_amount: vatAmount, + }) + + // Registration entry: DR expense + DR input VAT (or DR calc input VAT for reverse) / CR 2440 + const regLines: JELine[] = [] + regLines.push({ + account: inv.account, + debit: subtotalSek, + description: inv.description, + }) + if (reverse && treatment === 'reverse_charge') { + // Booked input + output VAT for EU services (rate * subtotal) + const calcVat = round2(subtotalSek * (inv.vatRate / 100)) + regLines.push({ account: '2645', debit: calcVat, description: 'Beräknad ingående moms (omv.)' }) + regLines.push({ account: '2614', credit: calcVat, description: 'Utgående moms omv.' }) + } else if (vatAmount > 0) { + regLines.push({ account: '2641', debit: vatSek, description: 'Ingående moms' }) + } + regLines.push({ + account: '2440', + credit: totalSek, + description: `Lev.skuld ${inv.supplierName}`, + }) + const regJe = await postEntry( + ctx, + fy, + inv.date, + `Lev.faktura ${inv.number} — ${inv.supplierName}`, + 'supplier_invoice_registered', + regLines, + { sourceId: data.id } + ) + await sb + .from('supplier_invoices') + .update({ registration_journal_entry_id: regJe }) + .eq('id', data.id) + + if (inv.paid && inv.paidAt) { + const payJe = await postEntry( + ctx, + fy, + inv.paidAt, + `Betalning lev.faktura ${inv.number}`, + 'supplier_invoice_paid', + [ + { account: '2440', debit: totalSek, description: `Reglering ${inv.supplierName}` }, + { account: '1930', credit: totalSek }, + ], + { sourceId: data.id } + ) + await sb + .from('supplier_invoices') + .update({ payment_journal_entry_id: payJe }) + .eq('id', data.id) + await sb.from('transactions').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + date: inv.paidAt, + description: `Betalning ${inv.supplierName} ${inv.number}`, + amount: -totalSek, + currency: 'SEK', + amount_sek: -totalSek, + category: 'expense_other', + is_business: true, + supplier_invoice_id: data.id, + journal_entry_id: payJe, + merchant_name: inv.supplierName, + import_source: 'demo_seed', + }) + } + return data.id +} + +// ─── FY2025 SEED ─────────────────────────────────────────────────────────── + +async function seedFY2025( + ctx: CompanyCtx, + customers: Record, + suppliers: Record +): Promise { + console.log('[4] FY2025: opening balances + invoices + expenses + salary') + + // Opening balance for 2025 (start small — 50k bank, no AR) + await postEntry( + ctx, + 2025, + dt(2025, 1, 1), + 'Ingående balans 2025', + 'opening_balance', + [ + { account: '1930', debit: 50000, description: 'Bank IB' }, + { account: '2081', credit: 50000, description: 'Aktiekapital' }, + ] + ) + + // Customer invoices: 78 invoices spread Jan–Dec 2025, all paid same week, + // mixing Klient AB / Berlin GmbH / Nordic Tech / Liten Studio. + const klient = customers['Klient AB'] + const berlin = customers['Berlin GmbH'] + const nordic = customers['Nordic Tech AS'] + const liten = customers['Liten Studio HB'] + + let invSeq = 1 + const seedInv = async ( + customerId: string, + customerName: string, + date: string, + paidAt: string, + subtotal: number, + vatTreatment: InvoiceSeed['vatTreatment'], + description: string + ) => { + const vatRate = vatTreatment === 'standard_25' ? 25 : 0 + const number = `F-2025${pad(invSeq++)}${pad(invSeq)}` + await createInvoice(ctx, 2025, { + number: `F-2025${String(invSeq).padStart(3, '0')}`, + customerId, + customerName, + date, + dueDate: dt( + 2025, + new Date(date).getMonth() + 2 > 12 ? 12 : new Date(date).getMonth() + 2, + Math.min(new Date(date).getDate(), 28) + ), + status: 'paid', + vatTreatment, + vatRate, + subtotal, + description, + paidAmount: round2(subtotal * (1 + vatRate / 100)), + paidAt, + }) + } + + // 48 weekly Klient AB invoices: ~28k each = ~1.34M + for (let week = 0; week < 48; week++) { + const day = new Date('2025-01-06') + day.setDate(day.getDate() + week * 7) + const due = new Date(day) + due.setDate(due.getDate() + 30) + const paid = new Date(day) + paid.setDate(paid.getDate() + 14) + const subtotal = 28800 // 24h × 1200 + invSeq++ + await createInvoice(ctx, 2025, { + number: `F-2025${String(invSeq).padStart(4, '0')}`, + customerId: klient, + customerName: 'Klient AB', + date: day.toISOString().slice(0, 10), + dueDate: due.toISOString().slice(0, 10), + status: 'paid', + vatTreatment: 'standard_25', + vatRate: 25, + subtotal, + description: `Konsulttjänster vecka ${week + 2}, 2025 — 24h`, + hours: 24, + unitPrice: 1200, + paidAmount: round2(subtotal * 1.25), + paidAt: paid.toISOString().slice(0, 10), + }) + } + + // 12 monthly Berlin GmbH workshops EU reverse charge: 25k × 12 = 300k + for (let m = 1; m <= 12; m++) { + const day = dt(2025, m, 15) + const dueD = new Date(day) + dueD.setDate(dueD.getDate() + 30) + const paid = new Date(day) + paid.setDate(paid.getDate() + 20) + invSeq++ + await createInvoice(ctx, 2025, { + number: `F-2025${String(invSeq).padStart(4, '0')}`, + customerId: berlin, + customerName: 'Berlin GmbH', + date: day, + dueDate: dueD.toISOString().slice(0, 10), + status: 'paid', + vatTreatment: 'reverse_charge', + vatRate: 0, + subtotal: 25000, + description: `Workshop fee — month ${m}/2025`, + paidAmount: 25000, + paidAt: paid.toISOString().slice(0, 10), + }) + } + + // 12 monthly Nordic Tech AS export: 13k × 12 = 156k + for (let m = 1; m <= 12; m++) { + const day = dt(2025, m, 20) + const dueD = new Date(day) + dueD.setDate(dueD.getDate() + 30) + const paid = new Date(day) + paid.setDate(paid.getDate() + 25) + invSeq++ + await createInvoice(ctx, 2025, { + number: `F-2025${String(invSeq).padStart(4, '0')}`, + customerId: nordic, + customerName: 'Nordic Tech AS', + date: day, + dueDate: dueD.toISOString().slice(0, 10), + status: 'paid', + vatTreatment: 'export', + vatRate: 0, + subtotal: 13000, + description: `Konsulttjänst export — månad ${m}/2025`, + paidAmount: 13000, + paidAt: paid.toISOString().slice(0, 10), + }) + } + + // 6 Liten Studio invoices spread across year: avg 8k each = 48k + for (let i = 0; i < 6; i++) { + const month = (i * 2 + 2) <= 12 ? i * 2 + 2 : 12 + const day = dt(2025, month, 10) + const dueD = new Date(day) + dueD.setDate(dueD.getDate() + 30) + const paid = new Date(day) + paid.setDate(paid.getDate() + 18) + invSeq++ + await createInvoice(ctx, 2025, { + number: `F-2025${String(invSeq).padStart(4, '0')}`, + customerId: liten, + customerName: 'Liten Studio HB', + date: day, + dueDate: dueD.toISOString().slice(0, 10), + status: 'paid', + vatTreatment: 'standard_25', + vatRate: 25, + subtotal: 8000, + description: `Konsulttjänst — ${i + 1}/6, 2025`, + hours: 8, + unitPrice: 1000, + paidAmount: 10000, + paidAt: paid.toISOString().slice(0, 10), + }) + } + // Total invoices: 48 + 12 + 12 + 6 = 78 ✓ (~1.84M revenue) + + // Monthly salary entries for Anna (full year 2025) — 12 × (gross 65000 → + // tax ~14300, net 50700, social fees 20423). Use simplified BAS: + // DR 7210 65000 / CR 2710 14300, CR 1930 50700 (one entry per month) + // DR 7510 20423 / CR 2731 20423 + for (let m = 1; m <= 12; m++) { + const payDate = dt(2025, m, 25) + const taxDate = dt(2025, m === 12 ? 12 : m + 1, 12) + await postEntry( + ctx, + 2025, + payDate, + `Lön Anna Andersson ${m}/2025`, + 'salary_payment', + [ + { account: '7010', debit: 65000, description: 'Bruttolön' }, + { account: '2710', credit: 14300, description: 'Innehållen skatt' }, + { account: '1930', credit: 50700, description: 'Nettolön Anna' }, + ] + ) + await postEntry( + ctx, + 2025, + payDate, + `Sociala avgifter Anna ${m}/2025`, + 'salary_payment', + [ + { account: '7510', debit: 20423, description: 'Sociala avgifter 31.42%' }, + { account: '2731', credit: 20423, description: 'Skuld sociala avgifter' }, + ] + ) + // Skatte- och avgiftsbetalning + await postEntry( + ctx, + 2025, + taxDate, + `Inbetalning skatt + sociala ${m}/2025`, + 'manual', + [ + { account: '2710', debit: 14300 }, + { account: '2731', debit: 20423 }, + { account: '1930', credit: 34723, description: 'Skattekonto' }, + ] + ) + } + + // 9 months WeWork rent (Apr–Dec) + let arrival25 = 1 + for (let m = 4; m <= 12; m++) { + const date = dt(2025, m, 1) + await createSupplierInvoice( + ctx, + 2025, + { + supplierId: suppliers['WeWork Stockholm AB'], + supplierName: 'WeWork Stockholm AB', + number: `WW-2025-${pad(m)}`, + date, + dueDate: dt(2025, m === 12 ? 12 : m + 1, 1), + receivedDate: date, + subtotal: 8500, + vatRate: 25, + account: '5010', + description: `Hyra coworking ${m}/2025`, + paid: true, + paidAt: dt(2025, m === 12 ? 12 : m + 1, 5), + }, + arrival25++ + ) + } + + // Monthly SaaS bundle (Notion + Linear) — booked as own entry per month + for (let m = 1; m <= 12; m++) { + const date = dt(2025, m, 5) + await postEntry( + ctx, + 2025, + date, + `SaaS-prenumerationer ${m}/2025`, + 'manual', + [ + { account: '5420', debit: 4200, description: 'Programvaror' }, + { account: '2645', debit: 1050, description: 'Beräknad ing.moms 25% (omv.)' }, + { account: '2614', credit: 1050, description: 'Utg.moms omv.' }, + { account: '1930', credit: 4200 }, + ] + ) + } + + // Monthly travel (resor) — varying amounts ~50k/yr total + const travelMonthly = [3500, 4200, 5100, 3800, 4500, 4900, 2800, 5300, 4600, 4100, 4800, 5200] + for (let m = 1; m <= 12; m++) { + const date = dt(2025, m, 28) + const gross = travelMonthly[m - 1] + const vat = round2(gross * 0.06 / 1.06) + const net = round2(gross - vat) + await postEntry( + ctx, + 2025, + date, + `Resekostnader ${m}/2025`, + 'manual', + [ + { account: '5800', debit: net, description: 'Reseutlägg netto' }, + { account: '2641', debit: vat, description: 'Ing.moms 6%' }, + { account: '1930', credit: gross }, + ] + ) + } + + // Monthly office supplies ~30k/yr + const officeMonthly = [2100, 2500, 1800, 3200, 2400, 2700, 1900, 2300, 2800, 2200, 2600, 3500] + for (let m = 1; m <= 12; m++) { + const date = dt(2025, m, 18) + const gross = officeMonthly[m - 1] + const vat = round2(gross * 0.25 / 1.25) + const net = round2(gross - vat) + await postEntry( + ctx, + 2025, + date, + `Kontorsmaterial ${m}/2025`, + 'manual', + [ + { account: '6110', debit: net, description: 'Kontorsmaterial netto' }, + { account: '2641', debit: vat, description: 'Ing.moms 25%' }, + { account: '1930', credit: gross }, + ] + ) + } + + // Monthly representation (50% deductible — booked as 6071 "ej avdragsgill" for simplicity) + for (let m = 1; m <= 12; m++) { + const date = dt(2025, m, 22) + const gross = 1800 + (m % 3) * 400 + const vat = round2(gross * 0.12 / 1.12) + const net = round2(gross - vat) + await postEntry( + ctx, + 2025, + date, + `Representation ${m}/2025`, + 'manual', + [ + { account: '6071', debit: net, description: 'Repr. extern, ej avdragsgill' }, + { account: '2641', debit: vat, description: 'Ing.moms 12% (avdragsgill del)' }, + { account: '1930', credit: gross }, + ] + ) + } + + // Monthly pension premium for Anna (TGL + ITP-liknande, ~2k/mån) + for (let m = 1; m <= 12; m++) { + const date = dt(2025, m, 27) + await postEntry( + ctx, + 2025, + date, + `Pensionspremie Anna ${m}/2025`, + 'manual', + [ + { account: '7410', debit: 2000, description: 'Tjänstepension' }, + { account: '1930', credit: 2000 }, + ] + ) + } + + // 4 quarterly OpenAI invoices (USD, import outside EU) + for (let q = 1; q <= 4; q++) { + const m = q * 3 + await createSupplierInvoice( + ctx, + 2025, + { + supplierId: suppliers['OpenAI LLC'], + supplierName: 'OpenAI LLC', + number: `OAI-2025-Q${q}`, + date: dt(2025, m, 5), + dueDate: dt(2025, m, 25), + receivedDate: dt(2025, m, 5), + subtotal: 320, + vatRate: 0, + account: '5420', + description: `OpenAI API usage Q${q}/2025`, + paid: true, + paidAt: dt(2025, m, 7), + currency: 'USD', + exchangeRate: 10.5, + reverseCharge: false, + vatTreatment: 'import_outside_eu', + }, + arrival25++ + ) + } + + // 4 quarterly Vercel invoices (USD) + for (let q = 1; q <= 4; q++) { + const m = q * 3 + await createSupplierInvoice( + ctx, + 2025, + { + supplierId: suppliers['Vercel Inc'], + supplierName: 'Vercel Inc', + number: `VER-2025-Q${q}`, + date: dt(2025, m, 1), + dueDate: dt(2025, m, 28), + receivedDate: dt(2025, m, 1), + subtotal: 120, + vatRate: 0, + account: '5420', + description: `Vercel Pro Q${q}/2025`, + paid: true, + paidAt: dt(2025, m, 3), + currency: 'USD', + exchangeRate: 10.5, + reverseCharge: false, + vatTreatment: 'import_outside_eu', + }, + arrival25++ + ) + } + + // 4 quarterly bank service fees + for (let q = 1; q <= 4; q++) { + const date = dt(2025, q * 3, 30) + await postEntry( + ctx, + 2025, + date, + `Bankavgifter Q${q}/2025`, + 'manual', + [ + { account: '6570', debit: 1500, description: 'Bankavgifter' }, + { account: '1930', credit: 1500 }, + ] + ) + } + + // VAT settlement summary at year-end (balance-sheet only — no P&L impact) + await postEntry( + ctx, + 2025, + dt(2025, 12, 31), + 'Avräkning moms 2025 (sammandrag)', + 'manual', + [ + { account: '2610', debit: 350000, description: 'Avr.utg.moms 25%' }, + { account: '2641', credit: 8830, description: 'Avr.ing.moms' }, + { account: '2650', credit: 341170, description: 'Skuld moms att betala' }, + ] + ) +} + +// ─── FY2026 SEED ─────────────────────────────────────────────────────────── + +async function seedFY2026Konsult( + ctx: CompanyCtx, + customers: Record, + suppliers: Record +): Promise { + console.log('[5] FY2026: opening balances + 32 customer invoices + state mix + Stripe + supplier') + + // Opening balance 2026 (per prompt: bank IB 142000) + await postEntry( + ctx, + 2026, + dt(2026, 1, 1), + 'Ingående balans 2026', + 'opening_balance', + [ + { account: '1930', debit: 142000, description: 'Bank SEB IB' }, + { account: '2081', credit: 50000, description: 'Aktiekapital' }, + { account: '2091', credit: 92000, description: 'Balanserat resultat' }, + ] + ) + + const klient = customers['Klient AB'] + const berlin = customers['Berlin GmbH'] + const nordic = customers['Nordic Tech AS'] + const helsinki = customers['Helsinki Oy'] + const liten = customers['Liten Studio HB'] + + let invSeq = 1 + const num = () => `F-2026${String(invSeq++).padStart(4, '0')}` + + // 18 weekly Klient AB Jan–Apr 2026 (16 weeks * but 18 invoices means biweekly-ish) + // Distribute 18 weekly across 16 weeks Jan 6 – Apr 27 + const klientDates: { date: string; week: number }[] = [] + let kd = new Date('2026-01-06') + for (let i = 0; i < 18; i++) { + klientDates.push({ date: kd.toISOString().slice(0, 10), week: i + 2 }) + kd.setDate(kd.getDate() + 7) + } + + // States: 18 paid+matched, 6 partial, 4 overdue 30+, 2 overdue 60+, 2 sent + // Total = 32. We'll allocate from the 18 Klient + 8 Berlin + 4 Nordic + 2 Helsinki: + // - 18 Klient: distribute states (some paid, some partial, some overdue, some sent) + // - 8 Berlin: mostly paid + // - 4 Nordic: mostly paid + // - 2 Helsinki: paid + // Per prompt 4 overdue >30 = 2× Klient AB, 1× Liten Studio, 1× Berlin + // 2 overdue >60 = (let's make) 2× Klient AB + + type Slot = { state: 'paid' | 'partial' | 'overdue30' | 'overdue60' | 'sent' } + const klientSlots: Slot[] = [ + ...Array(10).fill({ state: 'paid' }), + ...Array(2).fill({ state: 'overdue60' }), + ...Array(2).fill({ state: 'overdue30' }), + ...Array(3).fill({ state: 'partial' }), + ...Array(1).fill({ state: 'sent' }), + ] as Slot[] + + for (let i = 0; i < klientDates.length; i++) { + const s = klientSlots[i] ?? ({ state: 'paid' } as Slot) + const date = klientDates[i].date + const dueD = new Date(date) + dueD.setDate(dueD.getDate() + 30) + const subtotal = 28800 + const total = subtotal * 1.25 + const status = + s.state === 'paid' + ? 'paid' + : s.state === 'partial' + ? 'partially_paid' + : s.state === 'sent' + ? 'sent' + : 'overdue' + const paidAmount = + s.state === 'paid' ? total : s.state === 'partial' ? round2(total * 0.5) : 0 + const paidAt = + s.state === 'paid' + ? dt(2026, new Date(date).getMonth() + 1, Math.min(28, new Date(date).getDate() + 14)) + : s.state === 'partial' + ? dt(2026, new Date(date).getMonth() + 1, Math.min(28, new Date(date).getDate() + 20)) + : undefined + await createInvoice(ctx, 2026, { + number: num(), + customerId: klient, + customerName: 'Klient AB', + date, + dueDate: dueD.toISOString().slice(0, 10), + status, + vatTreatment: 'standard_25', + vatRate: 25, + subtotal, + description: `Konsulttjänster vecka ${klientDates[i].week}, 2026 — 24h`, + hours: 24, + unitPrice: 1200, + paidAmount, + paidAt, + }) + } + + // 8 Berlin GmbH fixed-fee workshops Jan–Apr; 1 overdue 30, rest paid + const berlinAmounts = [42000, 35000, 48000, 28000, 55000, 32000, 38000, 41000] + for (let i = 0; i < 8; i++) { + const month = Math.min(4, Math.floor(i / 2) + 1) + const date = dt(2026, month, 5 + (i % 2) * 14) + const dueD = new Date(date) + dueD.setDate(dueD.getDate() + 30) + const isOverdue = i === 7 // last one overdue + const paidAt = isOverdue + ? undefined + : dt(2026, month, Math.min(28, 5 + (i % 2) * 14 + 18)) + await createInvoice(ctx, 2026, { + number: num(), + customerId: berlin, + customerName: 'Berlin GmbH', + date, + dueDate: dueD.toISOString().slice(0, 10), + status: isOverdue ? 'overdue' : 'paid', + vatTreatment: 'reverse_charge', + vatRate: 0, + subtotal: berlinAmounts[i], + description: `Workshop ${i + 1}/2026 — Berlin GmbH`, + paidAmount: isOverdue ? 0 : berlinAmounts[i], + paidAt, + }) + } + + // 4 Nordic Tech AS export, all paid + for (let i = 0; i < 4; i++) { + const month = i + 1 + const date = dt(2026, month, 22) + const dueD = new Date(date) + dueD.setDate(dueD.getDate() + 30) + const paidAt = dt(2026, month + 1 > 12 ? 12 : month + 1, 10) + await createInvoice(ctx, 2026, { + number: num(), + customerId: nordic, + customerName: 'Nordic Tech AS', + date, + dueDate: dueD.toISOString().slice(0, 10), + status: 'paid', + vatTreatment: 'export', + vatRate: 0, + subtotal: 14000, + description: `Konsulttjänst export — månad ${month}/2026`, + paidAmount: 14000, + paidAt, + }) + } + + // 2 Helsinki Oy — 1 paid, 1 sent (not overdue per prompt distribution) + for (let i = 0; i < 2; i++) { + const month = i === 0 ? 2 : 4 + const date = dt(2026, month, 18) + const dueD = new Date(date) + dueD.setDate(dueD.getDate() + 30) + const isPaid = i === 0 + await createInvoice(ctx, 2026, { + number: num(), + customerId: helsinki, + customerName: 'Helsinki Oy', + date, + dueDate: dueD.toISOString().slice(0, 10), + status: isPaid ? 'paid' : 'sent', + vatTreatment: 'reverse_charge', + vatRate: 0, + subtotal: 20000, + description: `Konsulttjänst — Helsinki Oy ${month}/2026`, + paidAmount: isPaid ? 20000 : 0, + paidAt: isPaid ? dt(2026, month + 1, 5) : undefined, + }) + } + + // 1 Liten Studio overdue 30+ (per prompt) + await createInvoice(ctx, 2026, { + number: num(), + customerId: liten, + customerName: 'Liten Studio HB', + date: dt(2026, 3, 1), + dueDate: dt(2026, 4, 1), + status: 'overdue', + vatTreatment: 'standard_25', + vatRate: 25, + subtotal: 9500, + description: 'Konsulttjänst mars — Liten Studio', + paidAmount: 0, + }) + + // 4 May 2026 invoices — unpaid, no reminder yet + for (let i = 0; i < 4; i++) { + const date = dt(2026, 5, 1 + i) + const dueD = new Date(date) + dueD.setDate(dueD.getDate() + 30) + await createInvoice(ctx, 2026, { + number: num(), + customerId: klient, + customerName: 'Klient AB', + date, + dueDate: dueD.toISOString().slice(0, 10), + status: 'sent', + vatTreatment: 'standard_25', + vatRate: 25, + subtotal: 28800, + description: `Konsulttjänster maj — vecka ${18 + i}, 2026`, + hours: 24, + unitPrice: 1200, + paidAmount: 0, + }) + } + + // ── Stripe payouts (3 in May) — create 8 sub-invoices first, batch them + // We'll create 8 small "Stripe customer" invoices grouped into 3 payouts + const stripeCustomer = liten // reuse Liten as a generic Stripe billed party + const stripeBatches: Array<{ + payoutDate: string + grossAmounts: number[] + fee: number + net: number + }> = [ + { payoutDate: '2026-05-02', grossAmounts: [9400, 9400], fee: 566, net: 18234 }, + { payoutDate: '2026-05-04', grossAmounts: [9400], fee: 278, net: 9122 }, + { payoutDate: '2026-05-05', grossAmounts: [10000, 9000, 9750], fee: 863, net: 27887 }, + ] + for (const batch of stripeBatches) { + let batchNet = 0 + for (const gross of batch.grossAmounts) { + // Create invoice & mark paid via Stripe before payout + const subtotal = round2(gross / 1.25) + const invDate = dt( + 2026, + Number(batch.payoutDate.slice(5, 7)), + Number(batch.payoutDate.slice(8, 10)) - 1 + ) + const inv: InvoiceSeed = { + number: num(), + customerId: stripeCustomer, + customerName: 'Liten Studio HB', + date: invDate, + dueDate: invDate, + status: 'paid', + vatTreatment: 'standard_25', + vatRate: 25, + subtotal, + description: 'Stripe-betalning — engångsuppdrag', + paidAmount: gross, + paidAt: batch.payoutDate, + } + await createInvoice(ctx, 2026, inv) + batchNet += gross + } + // Stripe fee booking: DR 6570 (banking fees) / CR 1930 (reduces payout) + await postEntry( + ctx, + 2026, + batch.payoutDate, + `Stripe-avgift utbetalning ${batch.payoutDate}`, + 'manual', + [ + { account: '6570', debit: batch.fee, description: 'Stripe transaktionsavgift' }, + { account: '1930', credit: batch.fee }, + ] + ) + // Bank transaction for Stripe payout (combined net) — already booked individual incomings; + // here we add a memo transaction for the payout aggregation + await sb.from('transactions').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + date: batch.payoutDate, + description: `STRIPE PAYOUT ${batch.payoutDate}`, + amount: 0, + currency: 'SEK', + amount_sek: 0, + category: 'income_other', + is_business: true, + merchant_name: 'Stripe', + notes: `Aggregated payout: ${batch.grossAmounts.length} invoices, gross ${batchNet}, fee ${batch.fee}, net ${batch.net}`, + import_source: 'demo_seed', + }) + } + + // Supplier invoices Jan–Apr — arrival_number must be unique per company + // across both fiscal years, so continue from the highest existing number. + const { data: maxArr } = await sb + .from('supplier_invoices') + .select('arrival_number') + .eq('company_id', ctx.companyId) + .order('arrival_number', { ascending: false }) + .limit(1) + .maybeSingle() + let arrival = (maxArr?.arrival_number ?? 0) + 1 + // WeWork × 4 paid + 1 unpaid (May) + for (let m = 1; m <= 5; m++) { + const isPaid = m <= 4 + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['WeWork Stockholm AB'], + supplierName: 'WeWork Stockholm AB', + number: `WW-2026-${pad(m)}`, + date: dt(2026, m, 1), + dueDate: dt(2026, m === 12 ? 12 : m + 1, 1), + receivedDate: dt(2026, m, 1), + subtotal: 8500, + vatRate: 25, + account: '5010', + description: `Hyra coworking ${m}/2026`, + paid: isPaid, + paidAt: isPaid ? dt(2026, m, 5) : undefined, + }, + arrival++ + ) + } + + // Linear (EUR 89, reverse charge) × 4 paid + for (let m = 1; m <= 4; m++) { + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['Linear Software Inc'], + supplierName: 'Linear Software Inc', + number: `LIN-2026-${pad(m)}`, + date: dt(2026, m, 5), + dueDate: dt(2026, m, 25), + receivedDate: dt(2026, m, 5), + subtotal: 89, + vatRate: 25, + account: '5420', + description: 'Linear Standard subscription (monthly)', + paid: true, + paidAt: dt(2026, m, 7), + currency: 'EUR', + exchangeRate: 11.4, + reverseCharge: true, + vatTreatment: 'reverse_charge', + }, + arrival++ + ) + } + + // OpenAI × 2 paid (USD) + for (let i = 0; i < 2; i++) { + const m = i + 1 + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['OpenAI LLC'], + supplierName: 'OpenAI LLC', + number: `OAI-2026-${i + 1}`, + date: dt(2026, m, 10), + dueDate: dt(2026, m, 25), + receivedDate: dt(2026, m, 10), + subtotal: 250, + vatRate: 0, + account: '5420', + description: 'OpenAI API usage', + paid: true, + paidAt: dt(2026, m, 12), + currency: 'USD', + exchangeRate: 10.5, + reverseCharge: false, + vatTreatment: 'import_outside_eu', + }, + arrival++ + ) + } + + // Vercel × 1 paid (USD) + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['Vercel Inc'], + supplierName: 'Vercel Inc', + number: 'VER-2026-01', + date: dt(2026, 2, 1), + dueDate: dt(2026, 2, 28), + receivedDate: dt(2026, 2, 1), + subtotal: 120, + vatRate: 0, + account: '5420', + description: 'Vercel Pro hosting (Feb)', + paid: true, + paidAt: dt(2026, 2, 3), + currency: 'USD', + exchangeRate: 10.5, + reverseCharge: false, + vatTreatment: 'import_outside_eu', + }, + arrival++ + ) + + // Notion × 1 paid (USD) + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['Notion Labs Inc'], + supplierName: 'Notion Labs Inc', + number: 'NOT-2026-01', + date: dt(2026, 1, 5), + dueDate: dt(2026, 1, 25), + receivedDate: dt(2026, 1, 5), + subtotal: 96, + vatRate: 0, + account: '5420', + description: 'Notion Plus team plan', + paid: true, + paidAt: dt(2026, 1, 7), + currency: 'USD', + exchangeRate: 10.5, + reverseCharge: false, + vatTreatment: 'import_outside_eu', + }, + arrival++ + ) + + // Apple iPad Pro — fixed asset (1230) 18000 SEK + 25% moms + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['Apple Sweden AB'], + supplierName: 'Apple Sweden AB', + number: 'APP-2026-001', + date: dt(2026, 2, 14), + dueDate: dt(2026, 3, 14), + receivedDate: dt(2026, 2, 14), + subtotal: 18000, + vatRate: 25, + account: '1230', + description: 'iPad Pro 13" (anläggning)', + paid: true, + paidAt: dt(2026, 2, 16), + }, + arrival++ + ) + + // SJ × 3 paid resor (12% moms) + for (let i = 0; i < 3; i++) { + const month = (i + 1) + await createSupplierInvoice( + ctx, + 2026, + { + supplierId: suppliers['SJ AB'], + supplierName: 'SJ AB', + number: `SJ-2026-${pad(i + 1)}`, + date: dt(2026, month, 15), + dueDate: dt(2026, month, 25), + receivedDate: dt(2026, month, 15), + subtotal: 1200, + vatRate: 6, + account: '5800', + description: `Tågresa Stockholm-Göteborg ${month}/2026`, + paid: true, + paidAt: dt(2026, month, 16), + }, + arrival++ + ) + } + + // Salary entries Jan–Apr 2026 for Anna, Erik, Johan + const salaries = [ + { name: 'Anna Andersson', gross: 65000, tax: 14300, net: 50700, soc: 20423 }, + { name: 'Erik Ek', gross: 52000, tax: 11440, net: 40560, soc: 16338 }, + { name: 'Johan Lind', gross: 70000, tax: 15400, net: 54600, soc: 21994 }, + ] + for (let m = 1; m <= 4; m++) { + const payDate = dt(2026, m, 25) + const taxDate = dt(2026, m === 12 ? 12 : m + 1, 12) + let totalGross = 0 + let totalTax = 0 + let totalNet = 0 + let totalSoc = 0 + for (const s of salaries) { + totalGross += s.gross + totalTax += s.tax + totalNet += s.net + totalSoc += s.soc + } + await postEntry( + ctx, + 2026, + payDate, + `Lön ${m}/2026 — Anna, Erik, Johan`, + 'salary_payment', + [ + { account: '7010', debit: totalGross, description: 'Bruttolöner' }, + { account: '2710', credit: totalTax, description: 'Innehållen skatt' }, + { account: '1930', credit: totalNet, description: 'Nettolöner' }, + ] + ) + await postEntry( + ctx, + 2026, + payDate, + `Sociala avgifter ${m}/2026`, + 'salary_payment', + [ + { account: '7510', debit: totalSoc, description: 'Sociala avgifter 31.42%' }, + { account: '2731', credit: totalSoc }, + ] + ) + await postEntry( + ctx, + 2026, + taxDate, + `Inbetalning skatt + sociala ${m}/2026`, + 'manual', + [ + { account: '2710', debit: totalTax }, + { account: '2731', debit: totalSoc }, + { account: '1930', credit: totalTax + totalSoc, description: 'Skattekonto' }, + ] + ) + } +} + +// ─── Inbox / uncategorized / voucher gaps ────────────────────────────────── + +async function seedInboxAndUncategorized( + ctx: CompanyCtx, + suppliers: Record +): Promise { + console.log('[6] inbox AWS PDF + 5 uncategorized + voucher gaps') + + // Synthetic AWS PDF storage row (no actual file upload — storage path + // exists for demo, file content can be uploaded later via UI) + const fakeHash = 'demo' + Math.random().toString(36).slice(2, 18).padEnd(60, '0') + const { data: doc, error: docErr } = await sb + .from('document_attachments') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + storage_path: `${ctx.userId}/${ctx.companyId}/inbox/aws-2026-05-05.pdf`, + file_name: 'aws-2026-05-05.pdf', + file_size_bytes: 124567, + mime_type: 'application/pdf', + sha256_hash: fakeHash, + version: 1, + is_current_version: true, + uploaded_by: ctx.userId, + upload_source: 'email', + }) + .select('id') + .single() + if (docErr) throw new Error(`document_attachments AWS: ${docErr.message}`) + + await sb.from('invoice_inbox_items').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + status: 'ready', + source: 'email', + document_type: 'supplier_invoice', + email_from: 'aws-billing@amazon.com', + email_subject: 'Your AWS Invoice — May 2026', + email_received_at: '2026-05-05T07:34:00Z', + document_id: doc.id, + extracted_data: { + supplier_name: 'Amazon Web Services Inc', + invoice_number: 'INV-AWS-2026-0529', + invoice_date: '2026-05-04', + due_date: '2026-06-03', + currency: 'USD', + subtotal: 247.0, + vat_amount: 0, + total: 247.0, + line_items: [ + { description: 'EC2 — t3.medium hours', amount: 198.5 }, + { description: 'S3 — Standard storage', amount: 48.5 }, + ], + }, + confidence: 0.91, + }) + + // 5 uncategorized bank transactions, dated within 14 days of 2026-05-06 + const today = new Date('2026-05-06') + const minus = (n: number) => { + const d = new Date(today) + d.setDate(d.getDate() - n) + return d.toISOString().slice(0, 10) + } + await sb.from('transactions').insert([ + { + user_id: ctx.userId, + company_id: ctx.companyId, + date: minus(2), + description: 'SJ AB — biljett', + amount: -487, + currency: 'SEK', + amount_sek: -487, + category: null, + is_business: null, + merchant_name: 'SJ AB', + import_source: 'demo_seed', + }, + { + user_id: ctx.userId, + company_id: ctx.companyId, + date: minus(4), + description: 'RESTAURANG KVARTER', + amount: -1240, + currency: 'SEK', + amount_sek: -1240, + category: null, + is_business: null, + merchant_name: 'Restaurang Kvarter', + import_source: 'demo_seed', + }, + { + user_id: ctx.userId, + company_id: ctx.companyId, + date: minus(6), + description: 'LINEAR.APP', + amount: -1015, // EUR 89 ~ 1015 SEK; suspicious duplicate vs registered May invoice + currency: 'SEK', + amount_sek: -1015, + category: null, + is_business: null, + merchant_name: 'Linear Software', + import_source: 'demo_seed', + notes: 'Möjlig dubblettbokning vs registrerad maj-faktura', + }, + { + user_id: ctx.userId, + company_id: ctx.companyId, + date: minus(8), + description: 'ICA BROMMA', + amount: -312, + currency: 'SEK', + amount_sek: -312, + category: null, + is_business: null, + merchant_name: 'ICA Bromma', + import_source: 'demo_seed', + }, + { + user_id: ctx.userId, + company_id: ctx.companyId, + date: minus(11), + description: 'TRAFIK SL — månadskort', + amount: -156, + currency: 'SEK', + amount_sek: -156, + category: null, + is_business: null, + merchant_name: 'Trafik Stockholm', + import_source: 'demo_seed', + }, + ]) +} + +// ─── HOLDING company seed ────────────────────────────────────────────────── + +async function seedHolding(holding: CompanyCtx): Promise { + console.log('[H] Holding 2026 IB + dotterbolagsaktier') + await postEntry( + holding, + 2026, + dt(2026, 1, 1), + 'Ingående balans 2026', + 'opening_balance', + [ + { account: '1310', debit: 100000, description: 'Aktier i Konsult AB (dotterbolag)' }, + { account: '1930', debit: 250000, description: 'Bank Handelsbanken' }, + { account: '2081', credit: 50000, description: 'Aktiekapital' }, + { account: '2091', credit: 300000, description: 'Balanserat resultat' }, + ] + ) +} + +// ─── MAIN ────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log(`Seeding demo account for ${email}`) + console.log(`[1] Looking up user`) + const userId = await findUser(email) + console.log(` user_id = ${userId}`) + + if (force) { + console.log(`[!] --force: wiping existing demo companies`) + await wipeExisting(userId) + } else { + const { data: existing } = await sb + .from('companies') + .select('id, name') + .eq('created_by', userId) + .in('name', ['Konsult AB', 'Konsult Holding AB']) + if (existing && existing.length > 0) { + console.error( + `Demo companies already exist (${existing.map((e) => e.name).join(', ')}). Pass --force to wipe.` + ) + process.exit(1) + } + } + + const konsult = await seedKonsultAB(userId) + const holding = await seedHoldingAB(userId) + + // Set Emil's active company to Konsult AB + await sb + .from('user_preferences') + .upsert({ user_id: userId, active_company_id: konsult.companyId }, { onConflict: 'user_id' }) + + console.log('[3] Seeding customers, suppliers, employees') + const customers = await seedCustomers(konsult, [ + { + name: 'Klient AB', + customer_type: 'swedish_business', + org_number: '5566778899', + vat_number: 'SE556677889901', + vat_number_validated: true, + email: 'bo@klient.se', + country: 'SE', + address_line1: 'Storgatan 10', + postal_code: '111 44', + city: 'Stockholm', + default_payment_terms: 30, + }, + { + name: 'Nordic Tech AS', + customer_type: 'non_eu_business', + org_number: '999888777', + email: 'ola@nordictech.no', + country: 'NO', + address_line1: 'Karl Johans gate 12', + postal_code: '0154', + city: 'Oslo', + default_payment_terms: 30, + is_international: true, + }, + { + name: 'Berlin GmbH', + customer_type: 'eu_business', + vat_number: 'DE123456789', + vat_number_validated: true, + email: 'klaus@berlin.de', + country: 'DE', + address_line1: 'Hauptstraße 5', + postal_code: '10115', + city: 'Berlin', + default_payment_terms: 30, + is_international: true, + }, + { + name: 'Helsinki Oy', + customer_type: 'eu_business', + vat_number: 'FI12345678', + vat_number_validated: true, + email: 'mikko@helsinki.fi', + country: 'FI', + address_line1: 'Mannerheimintie 12', + postal_code: '00100', + city: 'Helsinki', + default_payment_terms: 30, + is_international: true, + }, + { + name: 'Liten Studio HB', + customer_type: 'swedish_business', + org_number: '9696969696', + email: 'info@litenstudio.se', + country: 'SE', + address_line1: 'Lillgatan 3', + postal_code: '222 33', + city: 'Malmö', + default_payment_terms: 30, + }, + ]) + + await seedCustomers(holding, [ + { + name: 'Konsult AB', + customer_type: 'swedish_business', + org_number: '5591234567', + vat_number: 'SE559123456701', + vat_number_validated: true, + email: 'info@konsult.se', + country: 'SE', + address_line1: 'Vasagatan 16', + postal_code: '111 20', + city: 'Stockholm', + default_payment_terms: 30, + }, + ]) + + const suppliers = await seedSuppliers(konsult, [ + { + name: 'Amazon Web Services Inc', + supplier_type: 'non_eu_business', + country: 'US', + default_currency: 'USD', + default_expense_account: '5420', + category: 'IT-tjänster', + }, + { + name: 'OpenAI LLC', + supplier_type: 'non_eu_business', + country: 'US', + default_currency: 'USD', + default_expense_account: '5420', + category: 'IT-tjänster', + }, + { + name: 'Vercel Inc', + supplier_type: 'non_eu_business', + country: 'US', + default_currency: 'USD', + default_expense_account: '5420', + category: 'IT-tjänster', + }, + { + name: 'Notion Labs Inc', + supplier_type: 'non_eu_business', + country: 'US', + default_currency: 'USD', + default_expense_account: '5420', + category: 'IT-tjänster', + }, + { + name: 'Linear Software Inc', + supplier_type: 'eu_business', + country: 'IE', + default_currency: 'EUR', + vat_number: 'IE3733749AH', + default_expense_account: '5420', + category: 'IT-tjänster', + }, + { + name: 'WeWork Stockholm AB', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '5010', + category: 'Hyra', + }, + { + name: 'Apple Sweden AB', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '5410', + category: 'IT-utrustning', + }, + { + name: 'SJ AB', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '5800', + category: 'Resor', + }, + { + name: 'Trafik Stockholm (SL)', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '5800', + category: 'Resor', + }, + { + name: 'ICA Bromma', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '6110', + category: 'Kontorsmaterial', + }, + { + name: 'Restaurang Kvarter', + supplier_type: 'swedish_business', + country: 'SE', + default_currency: 'SEK', + default_expense_account: '6071', + category: 'Representation', + }, + ]) + + const employees = await seedEmployees(konsult) + console.log(` ${Object.keys(customers).length} customers, ${Object.keys(suppliers).length} suppliers, ${Object.keys(employees).length} employees`) + + // FY2025 + await seedFY2025(konsult, customers, suppliers) + + // FY2026 + await seedFY2026Konsult(konsult, customers, suppliers) + + // Voucher gaps: requires that we delete the entries at A123 and A287 + // OR insert with skipped numbers from start. Easier: now that all 2025 + // entries are in, delete vouchers 123 and 287 from series A. + // BUT the immutability trigger will block deletion of posted entries. + // Solution: temporarily mark them as draft, delete, restore voucher seq. + // Even simpler: use raw SQL via Supabase MCP-style execute through service role + // which still hits triggers. Service role does NOT bypass triggers. + // + // Pragmatic approach: AFTER all entries are posted, NULL out and DELETE + // requires bypassing the trigger. The cleanest path is to simply NOT + // create entries at those slots — but our voucher counter is monotonic. + // We'll skip-numbers up-front by NOT actually creating the entries: + // Instead, we'll bump the counter by inserting then deleting the lines + // and the entry — which will fail. + // + // Real solution: emit a "draft" entry then leave it as draft forever. + // The detect_voucher_gaps RPC counts gaps among posted entries. + // BUT the seed already posted everything at sequence 1..N. So we need + // to retroactively introduce gaps. The SAFEST way is to bypass the + // immutability trigger by using a session_replication_role 'replica' + // via direct SQL. We'll do that via execute_sql below. + + // FY2026 inbox & uncategorized + await seedInboxAndUncategorized(konsult, suppliers) + + // Holding + await seedHolding(holding) + + console.log('[*] Seeding complete (voucher gaps script-side TODO via SQL)') + console.log('') + console.log('=== ENTITY SUMMARY ===') + for (const [label, cid] of [ + ['Konsult AB', konsult.companyId], + ['Konsult Holding AB', holding.companyId], + ]) { + const counts = await Promise.all([ + sb.from('customers').select('id', { count: 'exact', head: true }).eq('company_id', cid), + sb.from('suppliers').select('id', { count: 'exact', head: true }).eq('company_id', cid), + sb.from('employees').select('id', { count: 'exact', head: true }).eq('company_id', cid), + sb.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', cid), + sb.from('supplier_invoices').select('id', { count: 'exact', head: true }).eq('company_id', cid), + sb + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', cid), + sb.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', cid), + ]) + console.log( + `${label} (${cid}): ${counts[0].count} customers, ${counts[1].count} suppliers, ${counts[2].count} employees, ${counts[3].count} invoices, ${counts[4].count} sup.invoices, ${counts[5].count} journal entries, ${counts[6].count} bank txns` + ) + } + console.log('') + console.log('Manual setup still required (out of scope for this script):') + console.log(' - Gmail demo account: AWS billing email + Stripe payout confirmations') + console.log(' - Google Calendar: week 28 Apr–4 May meetings') + console.log(' - Google Drive: folder "Kvitton 2026" / "Bokslut 2025"') + console.log(' - Slack: #ekonomi channel + DM with gnubok-bot') + console.log(' - Voucher gaps A123 + A287 in FY2025: see scripts/seed-demo-voucher-gaps.sql') + console.log('') +} + +main().catch((err) => { + console.error('FATAL:', err) + process.exit(1) +}) diff --git a/supabase/migrations/20260506140100_fiscal_periods_exclude_per_company.sql b/supabase/migrations/20260506140100_fiscal_periods_exclude_per_company.sql new file mode 100644 index 00000000..b0040583 --- /dev/null +++ b/supabase/migrations/20260506140100_fiscal_periods_exclude_per_company.sql @@ -0,0 +1,19 @@ +-- Fix multi-tenant gap in fiscal_periods.no_overlapping_fiscal_periods +-- +-- The exclusion constraint was created before multi-tenant and scopes +-- overlap detection by user_id. After the multi-tenant refactor a single +-- user can own/be a member of multiple companies, which legitimately +-- have their own (overlapping) fiscal years. Rebind the exclusion to +-- company_id so the constraint reflects per-tenant uniqueness. + +ALTER TABLE public.fiscal_periods + DROP CONSTRAINT IF EXISTS no_overlapping_fiscal_periods; + +ALTER TABLE public.fiscal_periods + ADD CONSTRAINT no_overlapping_fiscal_periods + EXCLUDE USING gist ( + company_id WITH =, + daterange(period_start, period_end, '[]') WITH && + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260506160000_bankid_enrichment_table.sql b/supabase/migrations/20260506160000_bankid_enrichment_table.sql new file mode 100644 index 00000000..16d25930 --- /dev/null +++ b/supabase/migrations/20260506160000_bankid_enrichment_table.sql @@ -0,0 +1,28 @@ +-- BankID enrichment is user-level data fetched immediately after BankID auth, +-- before the user has selected or created a company. It cannot live in +-- extension_data, which migration 20260330130000 made company-scoped +-- (company_id NOT NULL). Every BankID signup since that refactor has silently +-- failed to persist enrichment because of the NOT NULL violation. + +CREATE TABLE public.bankid_enrichment ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + company_roles JSONB NOT NULL DEFAULT '[]'::jsonb, + enriched_at_utc TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.bankid_enrichment ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users read own enrichment" ON public.bankid_enrichment + FOR SELECT USING (auth.uid() = user_id); + +-- Writes happen only via service role (createServiceClient) inside the +-- TIC extension's BankID complete handler, so no user-facing INSERT/UPDATE +-- policy is needed. Service role bypasses RLS. + +CREATE TRIGGER set_updated_at_bankid_enrichment + BEFORE UPDATE ON public.bankid_enrichment + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +NOTIFY pgrst, 'reload schema';