From 980f29dae8f7404b3c7019fdfc1d71acda904e3f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 12 May 2026 18:04:48 +0200 Subject: [PATCH] Bug/momsdeklaration skv (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CLAUDE.md | 381 +++++--------- app/(dashboard)/reports/page.tsx | 1 + app/(dashboard)/skattekonto/page.tsx | 261 +++++++++- app/(dashboard)/transactions/page.tsx | 238 ++++++++- app/api/salary/employees/[id]/route.ts | 6 +- app/api/salary/employees/route.ts | 8 +- .../[id]/payslips/[employeeId]/pdf/route.ts | 8 +- .../salary/runs/[id]/payslips/send/route.ts | 8 +- app/api/salary/runs/[id]/route.ts | 5 +- components/reports/SkatteverketPanel.tsx | 88 +++- .../skattekonto/SkattekontoMatchDialog.tsx | 219 +++++++++ .../transactions/SkattekontoInboxCard.tsx | 189 +++++++ .../transactions/TransactionHistoryList.tsx | 465 +++++++++++++----- .../transactions/TransactionInboxCard.tsx | 20 +- extensions/general/mcp-server/server.ts | 11 +- .../skatteverket/__tests__/mappers.test.ts | 51 ++ .../__tests__/skattekonto-match.test.ts | 412 ++++++++++++++++ extensions/general/skatteverket/index.ts | 116 ++++- .../general/skatteverket/lib/mappers.ts | 26 +- .../skatteverket/lib/skattekonto-match.ts | 440 +++++++++++++++++ extensions/general/skatteverket/types.ts | 25 +- .../supplier-invoice-entries.test.ts | 109 +++- lib/bookkeeping/bas-data/class-4-purchases.ts | 11 + lib/bookkeeping/supplier-invoice-entries.ts | 114 ++++- lib/bookkeeping/vat-entries.ts | 85 ++++ .../__tests__/vat-declaration-checks.test.ts | 104 ++++ lib/reports/vat-declaration-checks.ts | 140 ++++++ lib/salary/__tests__/personnummer.test.ts | 8 +- lib/salary/pdf/payslip-template.tsx | 2 +- lib/salary/personnummer.ts | 7 +- .../__tests__/bank-counterpart.test.ts | 143 ++++++ lib/skatteverket/bank-counterpart.ts | 76 +++ types/skatteverket.ts | 53 ++ 33 files changed, 3337 insertions(+), 493 deletions(-) create mode 100644 components/skattekonto/SkattekontoMatchDialog.tsx create mode 100644 components/transactions/SkattekontoInboxCard.tsx create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-match.test.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-match.ts create mode 100644 lib/reports/__tests__/vat-declaration-checks.test.ts create mode 100644 lib/reports/vat-declaration-checks.ts create mode 100644 lib/skatteverket/__tests__/bank-counterpart.test.ts create mode 100644 lib/skatteverket/bank-counterpart.ts create mode 100644 types/skatteverket.ts diff --git a/CLAUDE.md b/CLAUDE.md index 03593523..f87d627c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ gnubok is a Swedish-focused accounting SaaS for sole traders (enskild firma) and **Tech stack**: Next.js 16.1.5 (App Router), React 19.2.3, TypeScript 5 (strict), Zod 4, Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting, Docker (self-hosted). -**Integrations**: Enable Banking (PSD2), TIC Identity (company lookup), Anthropic SDK, AWS Bedrock (`@aws-sdk/client-bedrock-runtime` for inbox smart-match), OpenAI (embeddings), Resend (email), Sentry (error tracking), Svix (webhooks), web-push (notifications), Upstash Redis + Ratelimit, Google Drive (cloud backup via OAuth), JSZip (archive export), sharp (image processing), Framer Motion (animations), Recharts (charts), PDF.js (`pdfjs-dist`), `@react-pdf/renderer` (invoice PDFs), xlsx, fuse.js (fuzzy search), ics (iCal feeds). +**Integrations**: Enable Banking (PSD2), TIC Identity, Anthropic SDK, AWS Bedrock, OpenAI, Resend, Sentry, Svix, web-push, Upstash Redis, Google Drive, JSZip, sharp, Framer Motion, Recharts, PDF.js, `@react-pdf/renderer`, xlsx, fuse.js, ics. **Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English. **License**: AGPL-3.0-or-later. @@ -27,59 +27,41 @@ npm run setup:extensions # Regenerate extension registry from extensions.config. ## Key Architectural Relationships -- **Multi-tenant model**: `companies` table owns all business data. `company_members` links users to companies with roles (owner/admin/member/viewer). `teams` group companies for consultants. Company context resolved via cookie (`gnubok-company-id`) in middleware (`lib/supabase/middleware.ts`). -- **All journal entry creation** routes through `lib/bookkeeping/engine.ts`. Lifecycle: `createDraftEntry()` → `commitEntry()` (atomic voucher assignment via `commit_journal_entry` DB RPC). Convenience: `createJournalEntry()` does both. Reversal via `reverseEntry()`. Correction via `correctEntry()` in `lib/core/bookkeeping/storno-service.ts`. -- **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level. This loads extensions, wires event handlers, and registers the supplier invoice handler + event log handler. -- **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Handlers run via `Promise.allSettled` — failing handlers never crash the emitter. 36 event types defined in `lib/events/types.ts`. The event log handler persists actionable events to `event_log` table for external automation. -- **Supabase clients**: browser (`lib/supabase/client.ts`), server with cookies (`createClient()` from `server.ts`), service role (`createServiceClient()`), cookieless service role for API key auth (`createServiceClientNoCookies()` from `lib/auth/api-keys.ts`). Pagination helper: `fetchAllRows()` in `lib/supabase/fetch-all.ts`. -- **Extension system**: Opt-in via `extensions.config.json`. Core builds and runs with zero extensions. Currently enabled: `enable-banking`, `email`, `arcim-migration`, `tic`, `mcp-server`, `cloud-backup`. -- **Core reports** (in `lib/reports/`, not extensions): balance sheet, income statement, trial balance, general ledger, AR/supplier ledger, AR/supplier reconciliation, VAT declaration, journal register, monthly breakdown, continuity check, opening balances, KPI (+ definitions), NE-bilaga, INK2 declaration, SIE export, full archive export, salary journal, vacation liability, avgifter basis (employer contributions). -- **Types**: All shared types in `types/index.ts` (~2,570 lines, single source of truth). Import via `import type { T } from '@/types'`. Event types live in `lib/events/types.ts`. Extension types in `lib/extensions/types.ts`. -- **Error messages**: `lib/errors/get-error-message.ts` maps technical errors to Swedish user messages (Zod → Postgres → HTTP → context fallback). +- **Multi-tenant model**: `companies` owns all business data. `company_members` links users to companies (owner/admin/member/viewer). `teams` group companies. Context resolved via `gnubok-company-id` cookie in `lib/supabase/middleware.ts`. +- **All journal entry creation** routes through `lib/bookkeeping/engine.ts`. Lifecycle: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. Reversal: `reverseEntry()`. Correction: `correctEntry()` in `lib/core/bookkeeping/storno-service.ts`. +- **API routes** emitting events must call `ensureInitialized()` (`lib/init.ts`) at module level to load extensions and wire handlers. +- **Event bus** (`lib/events/bus.ts`) is a module-level singleton using `Promise.allSettled`. 36 event types in `lib/events/types.ts`. Persisted to `event_log` table (30-day TTL). +- **Supabase clients**: browser (`client.ts`), server cookies (`createClient()`), service role (`createServiceClient()`), cookieless service role for API keys (`createServiceClientNoCookies()`). Pagination: `fetchAllRows()`. +- **Extension system**: Opt-in via `extensions.config.json`. Core runs with zero extensions. Enabled: `enable-banking`, `email`, `arcim-migration`, `tic`, `mcp-server`, `cloud-backup`. +- **Core reports** (`lib/reports/`): balance sheet, income statement, trial balance, general ledger, AR/supplier ledger + reconciliation, VAT declaration, journal register, monthly breakdown, continuity check, opening balances, KPI, NE-bilaga, INK2, SIE export, full archive, salary journal, vacation liability, avgifter basis. +- **Types**: Shared types in `types/index.ts` (~2,570 lines). Import via `import type { T } from '@/types'`. Event types in `lib/events/types.ts`. Extension types in `lib/extensions/types.ts`. +- **Error messages**: `lib/errors/get-error-message.ts` maps to Swedish (Zod → Postgres → HTTP → fallback). --- ## Multi-Tenant Architecture -### Data Model +- **companies**: Business unit. All business data has a `company_id` column. +- **company_members**: Roles `owner`/`admin`/`member`/`viewer`, source `direct`|`team`. +- **teams**: Consultant grouping. Team members auto-sync to company_members via DB triggers. +- **user_preferences**: Stores `active_company_id`. -- **companies**: Business unit (name, org_number, entity_type, created_by, team_id). All business data (journal entries, invoices, transactions, etc.) has a `company_id` column. -- **company_members**: Links users to companies (company_id, user_id, role, source='direct'|'team'). Roles: `owner`, `admin`, `member`, `viewer`. -- **teams**: Consultant grouping (name, created_by). A company can belong to one team. Team members auto-sync to company_members via DB triggers. -- **team_members**: Links users to teams (team_id, user_id, role='owner'|'admin'|'member'). -- **user_preferences**: Stores `active_company_id` per user. +**Context resolution** (`lib/supabase/middleware.ts`): cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()` helper. -### Company Context Resolution - -Middleware (`lib/supabase/middleware.ts`) resolves the active company on every request: -1. Check `gnubok-company-id` cookie -2. Fall back to `user_preferences.active_company_id` -3. Fall back to first company membership - -RLS policies use `user_company_ids()` DB helper function to filter by companies the user has access to. - -### Invitations - -- **company_invitations**: Email-based invites with `gnubok_inv_` prefixed tokens (SHA-256 hashed, 7-day TTL). -- **team_invitations**: Same pattern for team invites. -- Token generation: `lib/auth/invite-tokens.ts`. +**Invitations**: `company_invitations`/`team_invitations` with `gnubok_inv_` tokens (SHA-256, 7-day TTL). See `lib/auth/invite-tokens.ts`. --- ## Authentication -Supabase Auth with **email+password** (primary) and **magic link** (fallback). MFA via TOTP is supported. +Supabase Auth: email+password (primary), magic link (fallback), TOTP MFA. MFA enforced **application-side** (middleware + API routes), not in RLS. -MFA is enforced **application-side** (middleware + API routes), **not** in RLS policies. Controlled by two env vars: +- `NEXT_PUBLIC_SELF_HOSTED=true` → MFA never enforced +- `NEXT_PUBLIC_REQUIRE_MFA=true` → middleware redirects to `/mfa/enroll` or `/mfa/verify` until AAL2 -- `NEXT_PUBLIC_SELF_HOSTED=true` → MFA never enforced (users can enable voluntarily) -- `NEXT_PUBLIC_REQUIRE_MFA=true` (hosted/Vercel) → middleware redirects to `/mfa/enroll` or `/mfa/verify` until AAL2 - -**API route auth** (`lib/auth/require-auth.ts`): `requireAuth()` returns `{ user, supabase, error }` discriminated union, enforces MFA on hosted. - -**API keys** (`lib/auth/api-keys.ts`): SHA-256 hashed with `gnubok_sk_` prefix. Scoped permissions (`TOOL_SCOPE_MAP`). Rate limited at 100 RPM via atomic DB RPC (`validate_and_increment_api_key`). - -**Cron auth** (`lib/auth/cron.ts`): `verifyCronSecret()` with constant-time comparison. +**API route auth** (`lib/auth/require-auth.ts`): `requireAuth()` returns `{ user, supabase, error }`, enforces MFA on hosted. +**API keys** (`lib/auth/api-keys.ts`): SHA-256 hashed, `gnubok_sk_` prefix. Scoped via `TOOL_SCOPE_MAP`. Rate limited 100 RPM via `validate_and_increment_api_key` RPC. +**Cron auth** (`lib/auth/cron.ts`): `verifyCronSecret()` constant-time comparison. --- @@ -87,20 +69,11 @@ MFA is enforced **application-side** (middleware + API routes), **not** in RLS p The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it. -**Lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher assignment via `commit_journal_entry` DB RPC). Convenience: `createJournalEntry()` does both in one call. Reversal via `reverseEntry()` (storno). Correction via `correctEntry()` in `lib/core/bookkeeping/storno-service.ts`. +**Lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. `reverseEntry()` for storno; `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`) for corrections. -**Key engine files**: -- `transaction-entries.ts` — Journal entries from bank transactions -- `invoice-entries.ts` — Journal entries from customer invoices (`generatePerRateLines()` for mixed-rate) -- `supplier-invoice-entries.ts` — Journal entries from supplier invoices -- `vat-entries.ts` — VAT-related entries -- `currency-revaluation.ts` — Multi-currency revaluation -- `mapping-engine.ts` — Account mapping rules evaluation -- `booking-templates.ts` / `counterparty-templates.ts` — Reusable templates -- `propose-payment-lines.ts` / `propose-send-lines.ts` — AI-powered matching proposals -- `handlers/supplier-invoice-handler.ts` — Event handler creating registration entries on confirmation +**Engine files**: `transaction-entries.ts`, `invoice-entries.ts` (with `generatePerRateLines()` for mixed-rate), `supplier-invoice-entries.ts`, `vat-entries.ts`, `currency-revaluation.ts`, `mapping-engine.ts`, `booking-templates.ts`/`counterparty-templates.ts`, `propose-payment-lines.ts`/`propose-send-lines.ts`, `handlers/supplier-invoice-handler.ts`. -**BAS data** (`bookkeeping/bas-data/`): Full BAS 2026 chart organized by class (1–8) + SRU mapping. +**BAS data** (`bookkeeping/bas-data/`): Full BAS 2026 chart by class (1–8) + SRU mapping. ### Key BAS Accounts @@ -114,14 +87,13 @@ Invoice items support individual `vat_rate` values (mixed-rate invoices). Use `g ### VAT Declaration Rutor (SKV 4700) -The `VatDeclarationRutor` type maps to the Swedish tax authority's momsdeklaration form: - -- **Ruta 05**: Momspliktig försäljning — total domestic taxable sales (all rates combined, from 3001+3002+3003) -- **Ruta 06/07**: Unused (momspliktiga uttag / vinstmarginalbeskattning), always 0 -- **Ruta 10/11/12**: Utgående moms 25%/12%/6% — output VAT per rate (from 2611/2621/2631) -- **Ruta 39/40**: EU services / Export (from 3308/3305) -- **Ruta 48**: Ingående moms — input VAT (from 2641/2645) -- **Ruta 49**: Moms att betala/återfå = (ruta 10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - ruta 48 +`VatDeclarationRutor` type maps to momsdeklaration: +- **Ruta 05**: Domestic taxable sales (3001+3002+3003) +- **Ruta 06/07**: Unused, always 0 +- **Ruta 10/11/12**: Output VAT 25%/12%/6% (2611/2621/2631) +- **Ruta 39/40**: EU services / Export (3308/3305) +- **Ruta 48**: Input VAT (2641/2645) +- **Ruta 49**: Moms att betala/återfå = (10+11+12+30+31+32+60+61+62) − 48 --- @@ -156,59 +128,29 @@ These rules exist for legal compliance, enforced by database triggers. **Never v ## Extension System -Extensions are opt-in plugins in `extensions/general//`, controlled by `extensions.config.json`. Core builds and runs with zero extensions. `npm run setup:extensions` generates static imports in `lib/extensions/_generated/` (runs automatically via `predev`/`prebuild`). Extensions **cannot** use dynamic imports (Next.js bundling). +Extensions are opt-in plugins in `extensions/general//`, controlled by `extensions.config.json`. Core runs with zero extensions. `npm run setup:extensions` generates static imports in `lib/extensions/_generated/` (auto via `predev`/`prebuild`). Extensions **cannot** use dynamic imports. -### Available Extensions (12) +**Available (12)**: Enabled — `enable-banking` (PSD2), `email` (Resend), `arcim-migration`, `tic` (org lookup), `mcp-server`, `cloud-backup` (Google Drive). Disabled — `inbox-smart-match`, `invoice-inbox`, `push-notifications`, `calendar`, `skatteverket`, `example-logger`. -| Extension | Purpose | Currently Enabled | -|-----------|---------|:-:| -| `enable-banking` | PSD2 bank sync via Enable Banking | Yes | -| `email` | Email delivery via Resend | Yes | -| `arcim-migration` | Legacy ARCIM system data migration | Yes | -| `tic` | TIC Identity company lookup (org number → name, VAT, address) | Yes | -| `mcp-server` | MCP server for Claude Desktop/Code | Yes | -| `cloud-backup` | Google Drive backup of SIE + receipts + processing history | Yes | -| `inbox-smart-match` | AWS Bedrock AI matching of inbox receipts to bank transactions | No | -| `invoice-inbox` | Email-based invoice document processing | No | -| `push-notifications` | Web push notifications for events | No | -| `calendar` | Payment calendar with iCal feed | No | -| `skatteverket` | Skatteverket VAT declaration submission | No | -| `example-logger` | Reference implementation — logs events to console (not registered by default) | No | - -### Extension Architecture - -**Registration** (`lib/extensions/registry.ts`): Singleton registry. `register()` wires event handlers to the bus. `get(id)`, `getAll()`, `getByCapability(key)`. - -**Context** (`lib/extensions/context-factory.ts`): Every handler receives `ExtensionContext` with: `userId`, `companyId`, `extensionId`, `supabase`, `emit()`, `settings` (key-value in `extension_data` table), `storage` (Supabase Storage), `log` (prefixed logger), `services` (e.g., `ingestTransactions`). - -**API routes**: Dispatched via catch-all at `app/api/extensions/ext/[...path]/route.ts`. URL: `/api/extensions/ext/{extensionId}/{routePath}`. Path params extracted as `_paramName` search params. - -**Service provider patterns**: -- *Interface registration* (email): Core defines noop default in `lib/email/service.ts`, extension calls `registerEmailService()`, core uses `getEmailService()`. -- *Services record* (ai-categorization): Extension exposes via `services` property, core looks up via `extensionRegistry.get('id')?.services?.method(...)`. - -**Creating extensions**: `npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."`, then add to `extensions.config.json`. +**Registration** (`lib/extensions/registry.ts`): Singleton. `register()` wires handlers. `get(id)`, `getAll()`, `getByCapability(key)`. +**Context** (`lib/extensions/context-factory.ts`): `ExtensionContext` = `userId`, `companyId`, `extensionId`, `supabase`, `emit()`, `settings`, `storage`, `log`, `services`. +**API routes**: `app/api/extensions/ext/[...path]/route.ts` catch-all → `/api/extensions/ext/{extensionId}/{routePath}`. Path params as `_paramName` query. +**Service patterns**: Interface registration (email — `registerEmailService()`/`getEmailService()`) or services record (extension exposes via `services` property). +**Creating**: `npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."`. --- ## MCP Server & API Keys -gnubok exposes its bookkeeping engine as an MCP (Model Context Protocol) server, letting users do bookkeeping through Claude Desktop, Claude Code, or any MCP-compatible client. +gnubok exposes its bookkeeping engine as an MCP server for Claude Desktop/Code. -**MCP extension** (`extensions/general/mcp-server/`): 35 tools — transactions, categorization, customers, suppliers, invoices, supplier invoices, accounts, fiscal periods, trial balance, general ledger, balance sheet, income statement, AR/supplier ledger, reconciliation, VAT report, KPI report, receipt matching, invoice payments/sending, inbox items, employees + salary runs (list/get/create/calculate), salary journal, AGI generation, document upload. JSON-RPC 2.0 protocol implemented directly (no SDK dependency). Endpoint: `/api/extensions/ext/mcp-server/mcp`. +**MCP extension** (`extensions/general/mcp-server/`): 35 tools covering transactions, categorization, customers/suppliers, invoices, accounts, fiscal periods, reports (trial balance, GL, BS, IS, AR/supplier ledger, VAT, KPI), reconciliation, salary runs, AGI, document upload. JSON-RPC 2.0. Endpoint: `/api/extensions/ext/mcp-server/mcp`. -**API key infrastructure** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256 hashed keys with `gnubok_sk_` prefix. Scoped permissions mapped via `TOOL_SCOPE_MAP`. Rate limited at 100 RPM via atomic DB RPC (`validate_and_increment_api_key`). `createServiceClientNoCookies()` creates a Supabase service client without cookies for API key auth — all queries filter by `company_id` (defense in depth). +**API keys** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256, `gnubok_sk_` prefix, scoped via `TOOL_SCOPE_MAP`, 100 RPM via `validate_and_increment_api_key` RPC. `createServiceClientNoCookies()` — all queries filter by `company_id` (defense in depth). -**OAuth 2.1** for Claude Desktop connectors: -- `.well-known/oauth-protected-resource` and `.well-known/oauth-authorization-server` — discovery endpoints (excluded from auth middleware) -- `/api/mcp-oauth/authorize` — consent page + auth code generation -- `/api/mcp-oauth/token` — PKCE verification + API key creation -- `/api/mcp-oauth/register` — dynamic client registration -- Stateless encrypted auth codes (AES-256-GCM via `lib/auth/oauth-codes.ts`) -- Single-use enforcement via `oauth_used_codes` table -- Redirect URI allowlist: `claude.ai/api/*`, `claude.com/api/*`, `localhost` +**OAuth 2.1** for Claude connectors: `.well-known/oauth-protected-resource` + `.well-known/oauth-authorization-server` discovery; `/api/mcp-oauth/authorize`, `/token` (PKCE), `/register`. Stateless AES-256-GCM auth codes (`lib/auth/oauth-codes.ts`). Single-use via `oauth_used_codes`. Allowlist: `claude.ai/api/*`, `claude.com/api/*`, `localhost`. -**npm package** (`packages/gnubok-mcp`): Published as `gnubok-mcp` on npm. Stdio-to-HTTP bridge for Claude Desktop. Users configure `npx gnubok-mcp` with their API key. +**npm package** (`packages/gnubok-mcp`): Stdio-to-HTTP bridge; users run `npx gnubok-mcp` with API key. --- @@ -244,127 +186,60 @@ export async function POST(request: Request) { ## Key lib/ Directories -| Directory | Purpose | -|-----------|---------| -| `bookkeeping/` | Engine, entry generators, mapping, templates, BAS data, template library + embeddings | -| `core/` | Period service, year-end, storno, tax codes, audit, documents | -| `events/` | Event bus singleton, 36 event types, event log handler | -| `auth/` | API keys, require-auth, require-write, MFA, OAuth codes, invite tokens, cron auth, BankID | -| `supabase/` | Browser/server/service clients, middleware, fetch-all pagination | -| `api/` | Zod validation (`validateBody`/`validateQuery`), schemas | -| `reports/` | 20 report generators (financial statements, ledgers, tax, exports, salary journal, vacation liability, avgifter basis) | -| `invoices/` | Invoice/supplier matching, payment match log, reminders, VAT rules, PDF template | -| `transactions/` | Multi-source ingestion (`ingest.ts`), AI category suggestions | -| `import/` | SIE parser/import, bank file import, opening balance, account mapper | -| `documents/` | Document matcher, receipt matcher, batch matching | -| `extensions/` | Registry, loader, context factory, types, generated files | -| `email/` | Service interface (noop default), Resend provider, templates (invite, invoice, reminder, consent) | -| `company/` | Company context resolution, CRUD actions, fiscal period computation | -| `company-lookup/` | Shared types for org-number → company info lookups | -| `providers/` | Third-party accounting provider adapters (Fortnox, Bokio, Briox, BL/Björn Lundén, Visma) with OAuth, rate limiting, retry, consent resolution | -| `salary/` | Payroll calculation engine, tax tables, absence/benefits/traktamente, AGI, KU, PDF payslips, löneväxling, personnummer, payment, salary entries, transaction matcher | -| `processing-history/` | Processing-history append helper for audit/inbox timelines | -| `reconciliation/` | Bank statement reconciliation | -| `tax/` | Tax calculator, deadline config/generator, expense warnings, Swedish holidays | -| `vat/` | VIES client, EU countries, MOMS box mapping | -| `deadlines/` | Deadline status engine | -| `currency/` | Riksbanken exchange rates | -| `skatteverket/` | Tax authority data formatting | -| `bankgiro/` | Luhn checksum validation | -| `calendar/` | ICS generator, calendar utilities | -| `errors/` | Swedish error message mapping (Zod → Postgres → HTTP → fallback) | -| `rate-limits/` | Per-company Postgres-backed rate limiter (`checkInboxUploadRateLimit`) — used by inbox upload + email-inbound + retry-extraction. Calls `check_and_increment_inbox_quota` RPC; fails open on infra error. | -| `hooks/` | React hooks (e.g., `use-unsaved-changes`, `use-can-write`) | -| `logger.ts` | Structured logger with module prefixes, env-aware filtering | -| `support.ts` | Server-side support recipient email (used by `/api/support/contact`) | -| `utils.ts` | `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()` | +- `bookkeeping/` — Engine, entry generators, mapping, templates, BAS data +- `core/` — Period, year-end, storno, tax codes, audit, documents +- `events/` — Bus singleton, 36 event types, event log handler +- `auth/` — API keys, require-auth/write, MFA, OAuth codes, invite tokens, cron, BankID +- `supabase/` — Clients, middleware, `fetchAllRows` pagination +- `api/` — Zod validation (`validateBody`/`validateQuery`), schemas +- `reports/` — 20 report generators +- `invoices/` — Matching, payment log, reminders, VAT rules, PDF +- `transactions/` — `ingest.ts`, AI suggestions +- `import/` — SIE, bank file, opening balance, account mapper +- `documents/` — Matchers (single + batch) +- `extensions/` — Registry, loader, context factory +- `email/` — Service interface, Resend, templates +- `company/` — Context resolution, CRUD, fiscal period computation +- `providers/` — Fortnox, Bokio, Briox, BL, Visma (OAuth, retry, consent) +- `salary/` — Payroll engine, tax tables, AGI, KU, payslips, löneväxling, personnummer +- `processing-history/`, `reconciliation/`, `tax/`, `vat/` (VIES, MOMS box), `deadlines/`, `currency/` (Riksbanken), `skatteverket/`, `bankgiro/` (Luhn), `calendar/` (ICS) +- `errors/` — Swedish error mapping (Zod → Postgres → HTTP → fallback) +- `rate-limits/` — Postgres-backed `checkInboxUploadRateLimit` via `check_and_increment_inbox_quota` RPC; fails open +- `hooks/`, `logger.ts`, `support.ts`, `utils.ts` (`cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`) --- ## App Routes -### Pages +**Pages**: `/login`, `/register`, `/reset-password`, `/mfa/{enroll,verify}`, `/onboarding`, `/companies/new`, `/invite/[token]`, `/` (dashboard), `/transactions`, `/invoices[/new|/[id]|/[id]/credit]`, `/supplier-invoices[/new|/[id]]`, `/customers[/[id]]`, `/suppliers[/[id]]`, `/expenses[/new|/[id]]`, `/receipts[/scan]`, `/bookkeeping[/[id]|/year-end]`, `/salary[/employees|/runs]`, `/reports`, `/import`, `/kpi`, `/deadlines`, `/pending`, `/help`, `/extensions[/[sector]/[ext]]`, `/e/[sector]/[slug]` (workspace), `/settings/*`, `/dpa`, `/privacy`, `/invoice-action/[token]`, `/sandbox`. -| Route | Purpose | -|-------|---------| -| `/login`, `/register`, `/reset-password` | Auth pages | -| `/mfa/enroll`, `/mfa/verify` | MFA flow | -| `/onboarding` | Multi-step company setup wizard | -| `/companies/new` | Create new company | -| `/invite/[token]` | Accept team/company invite | -| `/` | Dashboard home | -| `/transactions` | Bank transaction list & categorization | -| `/invoices`, `/invoices/new`, `/invoices/[id]`, `/invoices/[id]/credit` | Customer invoicing | -| `/supplier-invoices`, `/supplier-invoices/new`, `/supplier-invoices/[id]` | Supplier invoices | -| `/customers`, `/customers/[id]` | Customer management | -| `/suppliers`, `/suppliers/[id]` | Supplier management | -| `/expenses`, `/expenses/new`, `/expenses/[id]` | Expense tracking | -| `/receipts`, `/receipts/scan` | Receipt management | -| `/bookkeeping`, `/bookkeeping/[id]`, `/bookkeeping/year-end` | Journal entries, chart of accounts, year-end | -| `/salary`, `/salary/employees`, `/salary/runs` | Payroll: employees, salary runs, AGI, KU | -| `/reports` | Financial reports | -| `/import` | SIE and bank file import | -| `/kpi` | KPI metrics + monthly trend chart | -| `/deadlines` | Tax & business deadlines | -| `/pending` | Pending operations queue | -| `/help` | In-app help/support page | -| `/extensions`, `/extensions/[sector]/[extension]` | Extension marketplace | -| `/e/[sector]/[slug]` | Extension workspace | -| `/settings/*` | Company, invoicing, bookkeeping, tax, team, banking, templates, salary, backup, account, API settings | -| `/dpa`, `/privacy` | Legal pages | -| `/invoice-action/[token]` | Public invoice payment link | -| `/sandbox` | Test environment | - -### API Endpoints (key groups) - -- `/api/bookkeeping/*` — Accounts, fiscal periods (close/lock/year-end/opening-balances/currency-revaluation), journal entries (CRUD/reverse/correct/chain), mapping rules, voucher gaps -- `/api/invoices/*` — CRUD, send, mark-sent/paid, convert, PDF, reminders cron -- `/api/supplier-invoices/*` — CRUD, approve, mark-paid, credit -- `/api/transactions/*` — Categorize, uncategorize, describe, book, match-invoice, match-supplier-invoice, batch operations, AI suggestions +**API endpoints**: +- `/api/bookkeeping/*` — accounts, fiscal periods, journal entries (CRUD/reverse/correct), mapping rules, voucher gaps +- `/api/invoices/*`, `/api/supplier-invoices/*` — CRUD + state transitions +- `/api/transactions/*` — categorize, describe, book, match-{invoice,supplier-invoice}, batch, AI suggestions - `/api/customers/*`, `/api/suppliers/*` — CRUD -- `/api/documents/*` — CRUD, versions, link, verify, match-sweep, verify cron -- `/api/reports/*` — 19 report endpoints (general-ledger, trial-balance, balance-sheet, income-statement, journal-register, ar-ledger, supplier-ledger, vat-declaration, sie-export, ink2, ne-bilaga, kpi, audit-trail, continuity-check, monthly-breakdown, full-archive, salary-journal, vacation-liability, avgifter-basis) -- `/api/salary/*` — Employees, payroll-config, tax-tables, KU, salary runs (CRUD + calculate) -- `/api/import/*` — Bank file (parse/execute), SIE (parse/execute/mappings/create-accounts) -- `/api/reconciliation/bank/*` — Link, unlink, run, status, unmatched-entries -- `/api/settings/*` — Company settings, API keys, logo upload, counterparty templates, booking templates -- `/api/company/*` — Current, members (list/CRUD/invite), `[id]` -- `/api/team/*` — Accept, invite, members -- `/api/deadlines/*`, `/api/tax-deadlines/*` — Deadline CRUD and crons -- `/api/pending-operations/*` — Queue, commit, reject -- `/api/events/*` — Event log and cleanup cron -- `/api/documents/*` — CRUD, counts, match-sweep, verify cron -- `/api/calendar/feed/[token]` — iCal subscription feed -- `/api/mcp-oauth/*` — Register, authorize, token -- `/api/support/contact` — Support contact form submission -- `/api/account/delete` — User account deletion -- `/api/audit-trail/*` — Audit trail queries -- `/api/log` — Client-side log ingestion -- `/api/health` — Health check -- `/api/vat/validate` — VIES VAT validation -- `/api/currency/rate` — Riksbanken exchange rate lookup -- `/api/sandbox/*` — Seed, cleanup cron -- `/api/extensions/ext/[...path]` — Dynamic extension API routes (plus top-level `/api/extensions/enable-banking/*`, `/api/extensions/cloud-backup/*`, `/api/extensions/push-notifications/*`) +- `/api/documents/*` — CRUD, versions, link, match-sweep, verify cron +- `/api/reports/*` — 19 endpoints (GL, TB, BS, IS, AR/supplier ledger, VAT, SIE, INK2, NE-bilaga, KPI, audit, continuity, monthly, full-archive, salary, vacation, avgifter) +- `/api/salary/*` — employees, payroll-config, tax-tables, KU, runs +- `/api/import/*` — bank-file, SIE (parse/execute/mappings) +- `/api/reconciliation/bank/*`, `/api/settings/*`, `/api/company/*`, `/api/team/*` +- `/api/deadlines/*`, `/api/tax-deadlines/*` — CRUD + crons +- `/api/pending-operations/*`, `/api/events/*`, `/api/audit-trail/*` +- `/api/calendar/feed/[token]`, `/api/mcp-oauth/*`, `/api/support/contact`, `/api/account/delete` +- `/api/log`, `/api/health`, `/api/vat/validate`, `/api/currency/rate`, `/api/sandbox/*` +- `/api/extensions/ext/[...path]` — dynamic extension routes --- ## Testing -**Framework**: Vitest 4, `globals: true`, `environment: 'node'`. Tests colocated in `__tests__/` directories. Scope: business logic in `lib/` and API routes in `app/api/`. No component or E2E tests. +**Framework**: Vitest 4, `node` env, tests in `__tests__/`. Scope: `lib/` and `app/api/`. No component/E2E tests. -**Test helpers** (`tests/helpers.ts`): `createMockSupabase()` (chainable proxy), `createQueuedMockSupabase()` (sequential calls), `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()`, and fixture factories: `makeTransaction()`, `makeJournalEntry()`, `makeJournalEntryLine()`, `makeInvoice()`, `makeInvoicePayment()`, `makeCustomer()`, `makeSupplier()`, `makeSupplierInvoice()`, `makeFiscalPeriod()`, `makeReceipt()`, `makeDocumentAttachment()`, `makeCompanySettings()`, `makeCompany()`, `makeCompanyMember()`, `makeInvoiceInboxItem()`, `makeTaxCode()`, `makeCategorizationTemplate()`, `makeSIEVoucher()`, `makeBankConnection()`. +**Helpers** (`tests/helpers.ts`): `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, `makeCustomer`, `makeSupplier`, `makeSupplierInvoice`, `makeFiscalPeriod`, `makeReceipt`, `makeDocumentAttachment`, `makeCompany`, `makeCompanySettings`, `makeTaxCode`, `makeSIEVoucher`, `makeBankConnection`, etc.). -**Patterns**: Always mock `@/lib/supabase/server`. Use `vi.clearAllMocks()` and `eventBus.clear()` in `beforeEach`. API route tests: mock `@/lib/init` and lib functions, test auth (401), validation (400), not found (404), errors (500), happy path. +**Patterns**: Always mock `@/lib/supabase/server`. `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Test auth (401), validation (400), 404, 500, happy path. -### Testing database-level logic (pg-real) - -Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies. A parallel Vitest project `pg-real` runs against a real Postgres instance in CI (GitHub Actions `supabase/postgres:15` service container, migrations replayed from `supabase/migrations/` before the suite runs). Locally: `npm run test:pg` against a DATABASE_URL pointing at any Postgres with the Supabase `auth` schema and migrations applied. - -**File convention**: `*.pg.test.ts`. The `unit` project excludes this suffix; only `pg-real` picks it up. - -**Helpers**: `tests/pg/setup.ts` exposes `getPool()` and `withUserContext(userId, fn)` (sets `ROLE authenticated` + `request.jwt.claims` for RLS tests). `tests/pg/fixtures.ts` has `seedCompany()`, `insertDraftJournalEntry()`, `insertBalancedLines()`, etc. - -**When to add a pg-real test**: any PR that creates or modifies a trigger, RPC, RLS policy, or DEFERRABLE constraint must include or extend a `*.pg.test.ts` test covering the new behavior. Mock coverage is not sufficient — it will pass on a broken migration. The suite is intentionally small (compliance gate, not a rewrite of the mock suite); add only smoke-level tests for new DB-layer constructs. +**pg-real**: Parallel Vitest project for triggers/RPCs/RLS using real Postgres (CI: `supabase/postgres:15`, migrations replayed). Local: `npm run test:pg`. File convention `*.pg.test.ts`. Helpers: `tests/pg/setup.ts` (`getPool()`, `withUserContext()`), `tests/pg/fixtures.ts` (`seedCompany()`, `insertDraftJournalEntry()`, etc.). **Required**: any PR touching a trigger/RPC/RLS/DEFERRABLE must include or extend a `*.pg.test.ts`. --- @@ -374,35 +249,21 @@ Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies ### Key Tables (~60) -**Multi-tenant**: `companies`, `company_members`, `company_invitations`, `teams`, `team_members`, `team_invitations`, `user_preferences`, `profiles` - -**Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `account_balances`, `voucher_sequences`, `voucher_gap_explanations` - -**Invoicing**: `customers`, `invoices`, `invoice_items`, `invoice_payments`, `invoice_inbox_items` - -**Suppliers**: `suppliers`, `supplier_invoices`, `supplier_invoice_items` - -**Banking**: `bank_connections`, `transactions`, `bank_file_imports`, `payment_match_log` - -**Documents**: `document_attachments` (WORM), `receipts`, `receipt_line_items` - -**Settings & Config**: `company_settings`, `mapping_rules`, `categorization_templates`, `booking_template_library`, `extension_data` - -**Dimensions**: `cost_centers`, `projects` - -**Tax & Deadlines**: `tax_rates`, `tax_table_rates`, `deadlines`, `calendar_feeds`, `skatteverket_tokens` - -**API & Auth**: `api_keys` (with scopes), `oauth_used_codes`, `bankid_identities` - -**Audit & Ops**: `audit_log` (immutable), `event_log` (30-day TTL), `pending_operations`, `processing_history` (+ `processing_event_types`), `ai_usage_tracking`, `voucher_gap_explanations`, `automation_webhooks` - -**Inbox & Migration**: `invoice_inbox_items`, `company_inboxes`, `email_connections` - -**Salary**: `employees`, `salary_runs`, `salary_run_employees`, `salary_line_items`, `salary_payroll_config`, `agi_declarations` - -**Third-party providers**: `provider_consents`, `provider_consent_tokens`, `provider_otc` - -**Other**: `sandbox_users` +- **Multi-tenant**: `companies`, `company_members`, `company_invitations`, `teams`, `team_members`, `team_invitations`, `user_preferences`, `profiles` +- **Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `account_balances`, `voucher_sequences`, `voucher_gap_explanations` +- **Invoicing**: `customers`, `invoices`, `invoice_items`, `invoice_payments`, `invoice_inbox_items` +- **Suppliers**: `suppliers`, `supplier_invoices`, `supplier_invoice_items` +- **Banking**: `bank_connections`, `transactions`, `bank_file_imports`, `payment_match_log` +- **Documents**: `document_attachments` (WORM), `receipts`, `receipt_line_items` +- **Settings**: `company_settings`, `mapping_rules`, `categorization_templates`, `booking_template_library`, `extension_data` +- **Dimensions**: `cost_centers`, `projects` +- **Tax/Deadlines**: `tax_rates`, `tax_table_rates`, `deadlines`, `calendar_feeds`, `skatteverket_tokens` +- **API/Auth**: `api_keys`, `oauth_used_codes`, `bankid_identities` +- **Audit/Ops**: `audit_log` (immutable), `event_log` (30d TTL), `pending_operations`, `processing_history`, `ai_usage_tracking`, `automation_webhooks` +- **Inbox**: `invoice_inbox_items`, `company_inboxes`, `email_connections` +- **Salary**: `employees`, `salary_runs`, `salary_run_employees`, `salary_line_items`, `salary_payroll_config`, `agi_declarations` +- **Providers**: `provider_consents`, `provider_consent_tokens`, `provider_otc` +- **Other**: `sandbox_users` ### Key RPC Functions @@ -431,14 +292,14 @@ Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies ### Migration Rules -1. **Always enable RLS** and create policies using `user_company_ids()` for company-scoped data -2. **Always add `updated_at` trigger** using `update_updated_at_column()` -3. **UUID primary keys**: `DEFAULT uuid_generate_v4()` -4. **Company ownership**: `company_id UUID REFERENCES companies NOT NULL` + `user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL` -5. **Never modify existing migrations** — create new ones -6. **Never modify enforcement triggers** (migration 017) — legally required -7. **Apply via Supabase MCP tool**: `mcp__plugin_supabase_supabase__apply_migration` -8. **Always include `NOTIFY pgrst, 'reload schema'`** at the end of migrations that alter table structure (ADD/DROP COLUMN, CREATE TABLE, ALTER TYPE). Without this, PostgREST serves stale schema until next reload. +1. Enable RLS + policies using `user_company_ids()` for company-scoped data +2. Add `updated_at` trigger via `update_updated_at_column()` +3. UUID PKs: `DEFAULT uuid_generate_v4()` +4. Company ownership: `company_id UUID REFERENCES companies NOT NULL` + `user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL` +5. Never modify existing migrations — create new ones +6. Never modify enforcement triggers (migration 017) — legally required +7. Apply via Supabase MCP `apply_migration` +8. Always end with `NOTIFY pgrst, 'reload schema'` when altering table structure --- @@ -461,18 +322,7 @@ Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies ### Vercel (Hosted) -Cron jobs defined in `vercel.json`: - -| Schedule | Endpoint | Purpose | -|----------|----------|---------| -| `0 6 * * *` | `/api/deadlines/status/cron` | Update deadline statuses | -| `0 8 * * *` | `/api/invoices/reminders/cron` | Send invoice reminders | -| `0 0 2 1 *` | `/api/tax-deadlines/cron` | Generate tax deadlines | -| `0 5 * * *` | `/api/extensions/enable-banking/sync/cron` | Bank transaction sync | -| `0 3 * * *` | `/api/documents/verify/cron` | Document integrity verification (daily) | -| `0 4 * * *` | `/api/sandbox/cleanup/cron` | Sandbox user cleanup | -| `0 2 * * *` | `/api/events/cleanup/cron` | Event log cleanup (30-day TTL) | -| `0 * * * *` | `/api/extensions/cloud-backup/auto-sync/cron` | Hourly cloud backup auto-sync | +Cron jobs in `vercel.json`: deadline status (`6:00`), invoice reminders (`8:00`), tax deadlines (yearly Jan 2), enable-banking sync (`5:00`), document verify (`3:00`), sandbox cleanup (`4:00`), event log cleanup (`2:00`, 30-day TTL), cloud-backup auto-sync (hourly). ### Docker (Self-Hosted) @@ -515,20 +365,15 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who ### Design Principles -1. **Clarity over cleverness.** Every element immediately understandable. Clear labels (in Swedish), obvious hierarchy. -2. **Earned minimalism.** Remove what doesn't serve the task, but don't strip context that prevents compliance errors. -3. **Numbers are first-class.** Tabular-nums, proper alignment, adequate contrast, clear positive/negative distinction. -4. **Trust through consistency.** Same patterns, spacing, and behavior everywhere. -5. **Speed is a feature.** Optimize for the 90-second session. +1. Clarity over cleverness — Swedish labels, obvious hierarchy. +2. Earned minimalism — remove what doesn't serve the task, keep compliance context. +3. Numbers are first-class — tabular-nums, alignment, positive/negative clarity. +4. Trust through consistency. +5. Speed is a feature — optimize for the 90-second session. ### Accessibility -- **WCAG AA**: 4.5:1 text contrast, 3:1 UI components -- Keyboard-navigable with visible focus rings -- Respect `prefers-reduced-motion` -- Color never sole indicator of state — always pair with icons, text, or shape -- Touch targets ≥ 40px (shadcn `Button size="icon"` default) — bump higher to 44px (WCAG AAA) for new mobile-critical surfaces -- Icon-only buttons must have `aria-label` +WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect `prefers-reduced-motion`. Color never sole state indicator. Touch targets ≥40px (44px for mobile-critical). Icon-only buttons need `aria-label`. ### Design System Tokens diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index dba8909b..1d679972 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1393,6 +1393,7 @@ function VatDeclarationView() { year={year} period={period} hasData={data !== null} + rutor={data?.rutor ?? null} /> {!data && !loading && !error && ( diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 0666d167..72c75a45 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -7,6 +7,14 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { @@ -14,10 +22,12 @@ import { ExternalLink, FileCheck, Landmark, + Link2, RefreshCw, } from 'lucide-react' import type { SkatteverketSaldoResponse, + SkattekontoTransactionWithSuggestion, StoredSkattekontoTransaction, } from '@/extensions/general/skatteverket/types' @@ -29,11 +39,22 @@ interface SaldoEnvelope { interface TransaktionerEnvelope { data: { - booked: StoredSkattekontoTransaction[] + booked: SkattekontoTransactionWithSuggestion[] upcoming: StoredSkattekontoTransaction[] } } +interface MatchCandidate { + journal_entry_id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' + matched_amount: number + matched_side: 'debit' | 'credit' +} + export default function SkattekontoPage() { const { toast } = useToast() const [saldo, setSaldo] = useState(null) @@ -42,6 +63,12 @@ export default function SkattekontoPage() { const [syncing, setSyncing] = useState(false) const [bookingId, setBookingId] = useState(null) const [notConnected, setNotConnected] = useState(false) + const [matchOpenFor, setMatchOpenFor] = useState( + null, + ) + const [matchCandidates, setMatchCandidates] = useState(null) + const [matchLoading, setMatchLoading] = useState(false) + const [matchSubmitting, setMatchSubmitting] = useState(null) const reload = useCallback(async () => { setLoading(true) @@ -130,6 +157,62 @@ export default function SkattekontoPage() { } } + async function openMatch(row: StoredSkattekontoTransaction) { + setMatchOpenFor(row) + setMatchCandidates(null) + setMatchLoading(true) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`, + ) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || 'Kunde inte söka kandidater') + } + setMatchCandidates(json.data.candidates as MatchCandidate[]) + } catch (err) { + toast({ + title: 'Kunde inte hämta kandidater', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + setMatchOpenFor(null) + } finally { + setMatchLoading(false) + } + } + + async function confirmMatch(journalEntryId: string) { + if (!matchOpenFor) return + setMatchSubmitting(journalEntryId) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${matchOpenFor.id}/match`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }, + ) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || 'Matchning misslyckades') + } + toast({ title: 'Transaktion kopplad till verifikat' }) + setMatchOpenFor(null) + setMatchCandidates(null) + await reload() + } catch (err) { + toast({ + title: 'Kunde inte koppla transaktionen', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setMatchSubmitting(null) + } + } + function copyOcr(ocr: string) { navigator.clipboard .writeText(ocr) @@ -182,7 +265,7 @@ export default function SkattekontoPage() { - Bokförda {tx?.booked ? `(${tx.booked.length})` : ''} + Genomförda {tx?.booked ? `(${tx.booked.length})` : ''} Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''} @@ -192,14 +275,16 @@ export default function SkattekontoPage() { + + { + setMatchOpenFor(null) + setMatchCandidates(null) + }} + onConfirm={confirmMatch} + /> ) } @@ -351,12 +448,14 @@ function BalanceHero({ function TransactionTable({ rows, onBokfor, + onMatch, bookingId, emptyText, showForfallodatum = false, }: { - rows: StoredSkattekontoTransaction[] + rows: SkattekontoTransactionWithSuggestion[] onBokfor: (id: string) => void + onMatch: (row: StoredSkattekontoTransaction) => void bookingId: string | null emptyText: string showForfallodatum?: boolean @@ -387,7 +486,18 @@ function TransactionTable({ {showForfallodatum && ( {row.forfallodatum ?? '–'} )} - {row.transaktionstext} + + {row.transaktionstext} + {!isBooked && row.match_suggestion && ( +

+ Möjlig dublett av{' '} + {row.match_suggestion.voucher_series && row.match_suggestion.voucher_number + ? `${row.match_suggestion.voucher_series}${row.match_suggestion.voucher_number}` + : 'utkast'}{' '} + ({row.match_suggestion.entry_date}) +

+ )} +
@@ -399,6 +509,10 @@ function TransactionTable({ Bokförd + ) : row.match_suggestion ? ( + + Möjlig dublett + ) : ( Ej bokförd )} @@ -411,14 +525,25 @@ function TransactionTable({ ) : ( - +
+ + +
)}
@@ -428,3 +553,113 @@ function TransactionTable({ ) } + +function MatchDialog({ + row, + candidates, + loading, + submittingId, + onClose, + onConfirm, +}: { + row: StoredSkattekontoTransaction | null + candidates: MatchCandidate[] | null + loading: boolean + submittingId: string | null + onClose: () => void + onConfirm: (journalEntryId: string) => void +}) { + const open = !!row + return ( + !o && onClose()}> + + + Matcha mot befintligt verifikat + + {row && ( + <> + {row.transaktionsdatum} • {row.transaktionstext} •{' '} + + {formatCurrency(Number(row.belopp_skatteverket))} + + + )} + + + + {loading && ( +

+ Söker kandidater… +

+ )} + + {!loading && candidates && candidates.length === 0 && ( +
+

Hittade inga verifikat med en matchande rad på konto 1630.

+

+ Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar + från transaktionsdatumet, och får inte redan vara kopplad till en + annan skattekonto-transaktion. Använd Bokför för + att skapa ett nytt verifikat istället. +

+
+ )} + + {!loading && candidates && candidates.length > 0 && ( +
+ + + + Datum + Verifikat + Beskrivning + Status + + + + + {candidates.map(c => ( + + {c.entry_date} + + {c.voucher_series && c.voucher_number + ? `${c.voucher_series}${c.voucher_number}` + : '–'} + + + {c.description} + + + {c.status === 'posted' ? ( + Bokförd + ) : c.status === 'draft' ? ( + Utkast + ) : ( + Makulerad + )} + + + + + + ))} + +
+
+ )} + + + + +
+
+ ) +} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index b13d3927..16f18f21 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' -import { X } from 'lucide-react' +import { Landmark, X } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView' import BatchCategorySelector from '@/components/transactions/BatchCategorySelector' @@ -19,6 +19,8 @@ import TransactionStatusBar from '@/components/transactions/TransactionStatusBar import TransactionInboxCard from '@/components/transactions/TransactionInboxCard' import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' import InboxZeroState from '@/components/transactions/InboxZeroState' +import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard' +import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import InvoicePicker from '@/components/transactions/InvoicePicker' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' @@ -31,9 +33,14 @@ import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' +import type { + SkattekontoTransactionWithSuggestion, + StoredSkattekontoTransaction, +} from '@/types/skatteverket' +import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart' import { useCompany } from '@/contexts/CompanyContext' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { formatCurrency, formatDate } from '@/lib/utils' +import { cn, formatCurrency, formatDate } from '@/lib/utils' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' @@ -121,6 +128,19 @@ export default function TransactionsPage() { // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(new Set()) + // Skattekonto rows (unmatched, status='booked'). Loaded if the + // Skatteverket extension is enabled and connected. 503/401 → silently + // hidden (extension disabled or user not connected). + const [skvRows, setSkvRows] = useState([]) + const [skvProcessingId, setSkvProcessingId] = useState(null) + const [skvMatchTarget, setSkvMatchTarget] = useState( + null, + ) + + // Source filter for the merged inbox. Defaults to 'all' so users see + // both sources unless they want to narrow down. + const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('all') + const { toast } = useToast() const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm() const supabase = createClient() @@ -139,6 +159,44 @@ export default function TransactionsPage() { if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch return b.date.localeCompare(a.date) }) + + // Merged inbox: bank tx + SKV rows interleaved by date. Source filter + // narrows to one side. SKV rows always go after bank rows on the same + // date — bank tx tend to have invoice-match suggestions and we'd rather + // surface those first. + type InboxItem = + | { source: 'bank'; date: string; data: TransactionWithInvoice } + | { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion } + + const skvUnmatched = skvRows.filter(r => !r.journal_entry_id) + + const bankToSkvHints = findBankSkvCounterparts({ + bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })), + skvRows: skvUnmatched, + }) + + const inboxItems: InboxItem[] = (() => { + const items: InboxItem[] = [] + if (sourceFilter !== 'skatteverket') { + for (const t of uncategorizedTransactions) { + items.push({ source: 'bank', date: t.date, data: t }) + } + } + if (sourceFilter !== 'bank') { + // Inbox only shows SKV rows that need action (no verifikat yet). + for (const r of skvRows) { + if (r.journal_entry_id) continue + if (exitingIds.has(r.id)) continue + items.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r }) + } + } + return items.sort((a, b) => { + if (a.date !== b.date) return b.date.localeCompare(a.date) + // Same date → bank first so invoice-match cards lead. + if (a.source !== b.source) return a.source === 'bank' ? -1 : 1 + return 0 + }) + })() const transactionsWithMatches = transactions.filter( (t) => (t.potential_invoice && !t.invoice_id) || @@ -202,6 +260,30 @@ export default function TransactionsPage() { setTotalUncategorizedCount(uncatCount ?? 0) setHasMore(rows.length >= PAGE_SIZE) setIsLoading(false) + + // Fire-and-forget: load SKV rows in parallel with the rest of the + // page. We don't block on this — if the extension is disabled or the + // user isn't connected the response is 503/401 and we just leave the + // SKV section empty. + void loadSkvRows() + } + + async function loadSkvRows() { + try { + const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner') + if (!res.ok) { + setSkvRows([]) + return + } + const json = await res.json() + const booked = (json.data?.booked ?? []) as SkattekontoTransactionWithSuggestion[] + // Keep all booked SKV rows in state — inbox view filters to obokförda + // (journal_entry_id null), history view shows all of them (matched + // and unmatched) interleaved with bank tx by date. + setSkvRows(booked) + } catch { + setSkvRows([]) + } } async function loadMoreTransactions() { @@ -690,6 +772,50 @@ export default function TransactionsPage() { } } + async function handleSkvBokfor(row: StoredSkattekontoTransaction) { + setSkvProcessingId(row.id) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`, + { method: 'POST' }, + ) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || 'Bokföring misslyckades') + } + toast({ + title: 'Utkast skapat', + description: 'Granska och bokför verifikatet i Bokföring.', + }) + window.location.href = `/bookkeeping/${json.data.entry.id}` + } catch (err) { + toast({ + title: 'Kunde inte bokföra', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setSkvProcessingId(null) + } + } + + function handleSkvMatched() { + // After a successful match, drop the row from the inbox — it's now + // linked to a verifikat. Trigger an exit animation first. + if (skvMatchTarget) { + const id = skvMatchTarget.id + setExitingIds(prev => new Set(prev).add(id)) + setTimeout(() => { + setSkvRows(prev => prev.filter(r => r.id !== id)) + setExitingIds(prev => { + const next = new Set(prev) + next.delete(id) + return next + }) + }, 350) + } + } + function handleTransactionBooked(transactionId: string, journalEntryId: string) { setExitingIds((prev) => new Set(prev).add(transactionId)) setTimeout(() => { @@ -939,42 +1065,99 @@ export default function TransactionsPage() { ))} ) : mode === 'inbox' ? ( - uncategorizedTransactions.length === 0 ? ( + inboxItems.length === 0 ? ( 0} + hasTransactions={transactions.length > 0 || skvRows.length > 0} onCreateTransaction={() => setIsDialogOpen(true)} /> ) : (
+ {/* Source filter — only render when both sources have content + to filter between, otherwise it'd be a no-op chip row. */} + {skvUnmatched.length > 0 && uncategorizedTransactions.length > 0 && ( +
+ Källa: + + + +
+ )} - {uncategorizedTransactions.map((transaction) => ( - - ))} + {inboxItems.map(item => + item.source === 'bank' ? ( + + ) : ( + setSkvMatchTarget(r)} + /> + ), + )}
) ) : ( setSkvMatchTarget(r)} hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMoreTransactions} @@ -1150,6 +1333,13 @@ export default function TransactionsPage() { + + setSkvMatchTarget(null)} + onMatched={handleSkvMatched} + /> ) } diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 930c3c7e..ac7b67b0 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate' import { UpdateEmployeeSchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' -import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' ensureInitialized() @@ -34,7 +34,7 @@ export async function GET( return NextResponse.json({ data: { ...employee, - personnummer: `XXXXXXXX-${employee.personnummer_last4}`, + personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), }, }) } @@ -117,7 +117,7 @@ export async function PATCH( return NextResponse.json({ data: { ...updated, - personnummer: `XXXXXXXX-${updated.personnummer_last4}`, + personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), }, }) } diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index c73a1a36..439aee7e 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate' import { CreateEmployeeSchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' -import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' ensureInitialized() @@ -34,10 +34,10 @@ export async function GET(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }) } - // Mask personnummer — only show last 4 + // Mask personnummer — show birthdate, hide the 4-digit suffix const masked = (data || []).map(emp => ({ ...emp, - personnummer: `XXXXXXXX-${emp.personnummer_last4}`, + personnummer: maskPersonnummer(decryptPersonnummer(emp.personnummer)), })) return NextResponse.json({ data: masked }) @@ -115,7 +115,7 @@ export async function POST(request: Request) { return NextResponse.json({ data: { ...employee, - personnummer: `XXXXXXXX-${last4}`, + personnummer: maskPersonnummer(body.personnummer), }, }, { status: 201 }) } diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts index 9bbd9406..5a474733 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts @@ -5,7 +5,7 @@ import { requireCompanyId } from '@/lib/company/context' import { renderToBuffer } from '@react-pdf/renderer' import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' -import { maskPersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' ensureInitialized() @@ -41,7 +41,7 @@ export async function GET( // Load salary run employee const { data: sre } = await supabase .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') .eq('salary_run_id', id) .eq('employee_id', employeeId) .single() @@ -62,7 +62,7 @@ export async function GET( } const emp = sre.employee as { - first_name: string; last_name: string; personnummer_last4: string; + first_name: string; last_name: string; personnummer: string; personnummer_last4: string; employment_type: string; tax_table_number: number | null; tax_column: number; clearing_number: string | null; bank_account_number: string | null; } @@ -104,7 +104,7 @@ export async function GET( companyName: company.name, companyOrgNumber: company.org_number || '', employeeName: `${emp.first_name} ${emp.last_name}`, - personnummerMasked: maskPersonnummer(emp.personnummer_last4), + personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)), employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type, periodYear: run.period_year, periodMonth: run.period_month, diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index 4d9d64c5..2ad0f22c 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -7,7 +7,7 @@ import { getEmailService } from '@/lib/email/service' import { renderToBuffer } from '@react-pdf/renderer' import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' -import { maskPersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' ensureInitialized() @@ -58,7 +58,7 @@ export async function POST( // Load employees with line items const { data: runEmployees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') .eq('salary_run_id', id) if (!runEmployees || runEmployees.length === 0) { @@ -75,7 +75,7 @@ export async function POST( for (const sre of runEmployees) { const emp = sre.employee as { - first_name: string; last_name: string; personnummer_last4: string; + first_name: string; last_name: string; personnummer: string; personnummer_last4: string; employment_type: string; email: string | null; tax_table_number: number | null; tax_column: number; clearing_number: string | null; bank_account_number: string | null; } | null @@ -116,7 +116,7 @@ export async function POST( companyName: company.name, companyOrgNumber: company.org_number || '', employeeName: `${emp.first_name} ${emp.last_name}`, - personnummerMasked: maskPersonnummer(emp.personnummer_last4), + personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)), employmentType: emp.employment_type, periodYear: run.period_year, periodMonth: run.period_month, diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index e49ca6ca..48d31051 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import { formatRedovisare } from '@/lib/skatteverket/format' +import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' ensureInitialized() @@ -32,7 +33,7 @@ export async function GET( // Load employees with line items const { data: employees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(id, first_name, last_name, personnummer_last4, employment_type), line_items:salary_line_items(*)') + .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)') .eq('salary_run_id', id) .order('created_at') @@ -61,7 +62,7 @@ export async function GET( ...emp, employee: emp.employee ? { ...emp.employee, - personnummer: `XXXXXXXX-${emp.employee.personnummer_last4}`, + personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)), } : null, })), }, diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index 03f8aff5..f948ae75 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -21,8 +21,12 @@ import { ShieldAlert, Trash2, } from 'lucide-react' -import type { VatPeriodType } from '@/types' +import type { VatDeclarationRutor, VatPeriodType } from '@/types' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' +import { + runVatDeclarationChecks, + type VatDeclarationCheck, +} from '@/lib/reports/vat-declaration-checks' interface SkatteverketStatus { connected: boolean @@ -45,6 +49,13 @@ interface SkatteverketPanelProps { year: number period: number hasData: boolean + /** + * Calculated rutor for the current period. Used to run local pre-flight + * checks before Skatteverket sees the payload — SKV only validates internal + * arithmetic consistency, so we have to catch "ruta 30-32 present but + * 20-24 empty" locally before letting the user submit. + */ + rutor?: VatDeclarationRutor | null } function formatAmount(amount: number): string { @@ -58,7 +69,7 @@ export function SkatteverketPanel(props: SkatteverketPanelProps) { return } -function SkatteverketPanelInner({ periodType, year, period, hasData }: SkatteverketPanelProps) { +function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) { const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) @@ -71,6 +82,14 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever tidpunkt?: string } | null>(null) + // Local sanity checks against the calculated declaration, run before any + // SKV call. SKV's "OK" only confirms arithmetic — these checks confirm + // the declaration looks plausible (no orphaned RC output, no missing + // basis, no summaMoms drift). + const localChecks: VatDeclarationCheck[] = rutor ? runVatDeclarationChecks(rutor) : [] + const localErrors = localChecks.filter((c) => c.status === 'ERROR') + const localBlocked = localErrors.length > 0 + const fetchStatus = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/skatteverket/status') @@ -133,6 +152,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever } const handleValidate = async () => { + if (localBlocked) { + setError( + 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + + 'du skickar till Skatteverket.', + ) + return + } setActionLoading('validate') setError(null) setKontroller([]) @@ -149,13 +175,19 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || [] setKontroller(controls) if (controls.length === 0) { - setSuccess('Valideringen godkänd — inga fel eller varningar') + // SKV's OK only confirms arithmetic — it does NOT confirm that the + // declaration is materially correct. We say so explicitly so the + // user doesn't read this as a green light for actual filing. + setSuccess( + 'Skatteverket har inga tekniska invändningar mot deklarationen. ' + + 'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.', + ) } else { const errors = controls.filter(k => k.status === 'ERROR') if (errors.length > 0) { setError(`${errors.length} valideringsfel hittades`) } else { - setSuccess('Valideringen godkänd med varningar') + setSuccess('Skatteverket har inga tekniska invändningar (med varningar)') } } } @@ -167,6 +199,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever } const handleSaveDraft = async () => { + if (localBlocked) { + setError( + 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + + 'du sparar utkastet hos Skatteverket.', + ) + return + } setActionLoading('draft') setError(null) try { @@ -439,11 +478,42 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever )} - {/* Validation results */} + {/* Local pre-flight check results — surfaced separately from SKV's + kontroller so the user knows these are gnubok's own sanity checks, + not Skatteverket's. ERRORs block the submit/validate buttons. */} + {localChecks.length > 0 && ( +
+

+ Lokala kontroller +

+ {localChecks.map((c, i) => ( +
+ {c.status === 'ERROR' ? ( + + ) : ( + + )} +
+ {c.code} + {c.message} +
+
+ ))} +
+ )} + + {/* Validation results from Skatteverket */} {kontroller.length > 0 && (

- Valideringsresultat + Skatteverkets valideringsresultat

{kontroller.map((k, i) => (
{actionLoading === 'validate' ? ( @@ -528,8 +599,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever variant="outline" size="sm" onClick={handleSaveDraft} - disabled={!hasData || actionLoading !== null} + disabled={!hasData || localBlocked || actionLoading !== null} className="gap-1.5" + title={localBlocked ? 'Åtgärda lokala kontrollfel innan inlämning' : undefined} > {actionLoading === 'draft' ? ( diff --git a/components/skattekonto/SkattekontoMatchDialog.tsx b/components/skattekonto/SkattekontoMatchDialog.tsx new file mode 100644 index 00000000..dd3dbb9a --- /dev/null +++ b/components/skattekonto/SkattekontoMatchDialog.tsx @@ -0,0 +1,219 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency } from '@/lib/utils' +import type { StoredSkattekontoTransaction } from '@/types/skatteverket' + +interface MatchCandidate { + journal_entry_id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' + matched_amount: number + matched_side: 'debit' | 'credit' +} + +/** + * Shared dialog for linking a skattekonto_transactions row to an existing + * journal entry. Used by both /skattekonto and /transactions so we don't + * have two copies of the same dialog drifting apart. + * + * The dialog owns its own data fetch — pass the row + open flag and it + * handles the rest. On successful match it calls onMatched(), letting the + * caller refresh its data. + */ +export function SkattekontoMatchDialog({ + row, + open, + onClose, + onMatched, +}: { + row: StoredSkattekontoTransaction | null + open: boolean + onClose: () => void + onMatched: () => void +}) { + const { toast } = useToast() + const [candidates, setCandidates] = useState(null) + const [loading, setLoading] = useState(false) + const [submittingId, setSubmittingId] = useState(null) + + useEffect(() => { + if (!open || !row) { + setCandidates(null) + return + } + let cancelled = false + setLoading(true) + ;(async () => { + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`, + ) + const json = await res.json() + if (cancelled) return + if (!res.ok) { + throw new Error(json.error || 'Kunde inte söka kandidater') + } + setCandidates(json.data.candidates as MatchCandidate[]) + } catch (err) { + if (cancelled) return + toast({ + title: 'Kunde inte hämta kandidater', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + onClose() + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [open, row, toast, onClose]) + + async function confirmMatch(journalEntryId: string) { + if (!row) return + setSubmittingId(journalEntryId) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }, + ) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || 'Matchning misslyckades') + } + toast({ title: 'Transaktion kopplad till verifikat' }) + onMatched() + onClose() + } catch (err) { + toast({ + title: 'Kunde inte koppla transaktionen', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setSubmittingId(null) + } + } + + return ( + !o && onClose()}> + + + Matcha mot befintligt verifikat + + {row && ( + <> + {row.transaktionsdatum} • {row.transaktionstext} •{' '} + + {formatCurrency(Number(row.belopp_skatteverket))} + + + )} + + + + {loading && ( +

+ Söker kandidater… +

+ )} + + {!loading && candidates && candidates.length === 0 && ( +
+

Hittade inga verifikat med en matchande rad på konto 1630.

+

+ Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar + från transaktionsdatumet, och får inte redan vara kopplad till en + annan skattekonto-transaktion. Använd Bokför för + att skapa ett nytt verifikat istället. +

+
+ )} + + {!loading && candidates && candidates.length > 0 && ( +
+ + + + Datum + Verifikat + Beskrivning + Status + + + + + {candidates.map(c => ( + + {c.entry_date} + + {c.voucher_series && c.voucher_number + ? `${c.voucher_series}${c.voucher_number}` + : '–'} + + + {c.description} + + + {c.status === 'posted' ? ( + Bokförd + ) : c.status === 'draft' ? ( + Utkast + ) : ( + Makulerad + )} + + + + + + ))} + +
+
+ )} + + + + +
+
+ ) +} diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx new file mode 100644 index 00000000..f1a79985 --- /dev/null +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -0,0 +1,189 @@ +'use client' + +import { motion } from 'framer-motion' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { AlertCircle, ArrowUpRight, ArrowDownRight, Landmark, Link2, Loader2 } from 'lucide-react' +import type { + SkattekontoMatchSuggestion, + StoredSkattekontoTransaction, +} from '@/types/skatteverket' + +/** + * Skattekonto-rad in the /transactions inbox. + * + * Mirrors the visual rhythm of TransactionInboxCard (same icon circle, same + * amount placement) but with SKV-specific actions: Bokför creates a draft via + * the skatteverket extension; Matcha opens the shared dialog so users can + * link the row to an already-booked manual transfer. + * + * The Skatteverket badge is the cue that this row is fundamentally different + * from a bank tx — different counter-account (1630 vs 1930), different + * categorization rules, no AI-suggested invoice matches. + */ +export default function SkattekontoInboxCard({ + row, + matchSuggestion, + processing, + onBokfor, + onMatch, + onAnimationComplete, +}: { + row: StoredSkattekontoTransaction + matchSuggestion?: SkattekontoMatchSuggestion | null + processing: boolean + onBokfor: (row: StoredSkattekontoTransaction) => void + onMatch: (row: StoredSkattekontoTransaction) => void + onAnimationComplete?: (id: string) => void +}) { + const amount = Number(row.belopp_skatteverket) + const isIncome = amount > 0 + + return ( + { + if ( + typeof definition === 'object' && + 'opacity' in definition && + definition.opacity === 0 + ) { + onAnimationComplete?.(row.id) + } + }} + > + + +
+
+ +
+
+

{row.transaktionstext}

+ + + Skatteverket + +
+

+ {formatDate(row.transaktionsdatum)} +

+
+
+ +
+

+ {isIncome ? '+' : ''} + {formatCurrency(amount)} +

+
+
+ + {matchSuggestion && ( +
+ +
+

+ Möjlig dublett av verifikat{' '} + {matchSuggestion.voucher_series && matchSuggestion.voucher_number + ? `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}` + : '(utkast)'} +

+

+ {matchSuggestion.entry_date} • {matchSuggestion.description} +

+

+ Det här ser ut som samma kassaflöde — koppla istället för + att bokföra om. +

+
+
+ )} + +
+ {matchSuggestion ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ ) +} diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index 4ff5c604..12d94783 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -1,20 +1,46 @@ 'use client' import { useState } from 'react' +import Link from 'next/link' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { formatCurrency, formatDate } from '@/lib/utils' +import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' -import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText, Loader2 } from 'lucide-react' +import { + Search, + ArrowUpRight, + ArrowDownRight, + ArrowLeftRight, + Check, + Landmark, + Link2, + FileText, + Loader2, + Trash2, +} from 'lucide-react' import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' import type { TransactionWithInvoice, HistoryFilter } from './transaction-types' +import type { + SkattekontoTransactionWithSuggestion, + StoredSkattekontoTransaction, +} from '@/types/skatteverket' + +type SourceFilter = 'all' | 'bank' | 'skatteverket' + +type HistoryRow = + | { source: 'bank'; date: string; data: TransactionWithInvoice } + | { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion } interface TransactionHistoryListProps { transactions: TransactionWithInvoice[] + skvRows?: SkattekontoTransactionWithSuggestion[] onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void + onDelete?: (id: string) => void + onSkvBokfor?: (row: StoredSkattekontoTransaction) => void + onSkvMatch?: (row: StoredSkattekontoTransaction) => void hasMore?: boolean isLoadingMore?: boolean onLoadMore?: () => void @@ -22,16 +48,25 @@ interface TransactionHistoryListProps { export default function TransactionHistoryList({ transactions, + skvRows = [], onOpenMatchDialog, onOpenCategoryDialog, + onDelete, + onSkvBokfor, + onSkvMatch, hasMore, isLoadingMore, onLoadMore, }: TransactionHistoryListProps) { const [searchTerm, setSearchTerm] = useState('') const [filter, setFilter] = useState('all') + const [sourceFilter, setSourceFilter] = useState('all') - const filtered = transactions.filter((t) => { + // The bank/private filter doesn't apply to SKV rows — they have no + // is_business flag. So when the filter is 'business' or 'private' we + // implicitly hide SKV (it doesn't match either). Source filter narrows + // further if the user picks one explicitly. + const bankFiltered = transactions.filter((t) => { const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase()) const matchesFilter = filter === 'all' || @@ -40,6 +75,30 @@ export default function TransactionHistoryList({ return matchesSearch && matchesFilter }) + const skvFiltered = skvRows.filter((r) => { + if (filter !== 'all') return false + return r.transaktionstext.toLowerCase().includes(searchTerm.toLowerCase()) + }) + + const merged: HistoryRow[] = [] + if (sourceFilter !== 'skatteverket') { + for (const t of bankFiltered) { + merged.push({ source: 'bank', date: t.date, data: t }) + } + } + if (sourceFilter !== 'bank') { + for (const r of skvFiltered) { + merged.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r }) + } + } + merged.sort((a, b) => { + if (a.date !== b.date) return b.date.localeCompare(a.date) + return a.source === 'bank' ? -1 : 1 + }) + + const showSourceFilter = skvRows.length > 0 && transactions.length > 0 + const filtered = merged + return (
{/* Search + filter pills */} @@ -68,6 +127,27 @@ export default function TransactionHistoryList({
+ {showSourceFilter && ( +
+ Källa: + {(['all', 'bank', 'skatteverket'] as const).map((s) => ( + + ))} +
+ )} + {/* Transaction list */} {filtered.length === 0 ? ( @@ -83,124 +163,24 @@ export default function TransactionHistoryList({ ) : (
- {filtered.map((transaction) => ( - - -
-
-
0 - ? 'bg-success/10 text-success' - : 'bg-destructive/10 text-destructive' - }`} - > - {transaction.amount > 0 ? ( - - ) : ( - - )} -
-
-
-

{transaction.description}

- -
-
- {formatDate(transaction.date)} - {transaction.is_business !== null && - !( - transaction.is_business && - transaction.category === 'uncategorized' && - transaction.journal_entry_id - ) && ( - <> - · - - {transaction.is_business - ? getCategoryDisplayName(transaction.category) - : 'Privat'} - - - )} - {transaction.invoice_id && ( - <> - · - - - Kopplad till faktura - - - )} - {transaction.journal_entry_id ? ( - <> - · - - - Bokförd - - - ) : ( - <> - · - - - )} - {transaction.potential_invoice && !transaction.invoice_id && ( - <> - · - - - )} -
-
-
-
- {!transaction.journal_entry_id && ( - - )} -
-

- {transaction.amount > 0 ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek && ( -

- {formatCurrency(transaction.amount_sek)} -

- )} -
-
-
-
-
- ))} + {filtered.map((item) => + item.source === 'bank' ? ( + + ) : ( + + ), + )} {hasMore && onLoadMore && !searchTerm && (
+ + )} + {transaction.potential_invoice && !transaction.invoice_id && ( + <> + · + + + )} +
+
+
+
+ {!transaction.journal_entry_id && ( + + )} + {!transaction.journal_entry_id && onDelete && ( + + )} +
+

+ {transaction.amount > 0 ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ {transaction.currency !== 'SEK' && transaction.amount_sek && ( +

+ {formatCurrency(transaction.amount_sek)} +

+ )} +
+
+ + + + ) +} + +function SkattekontoHistoryRow({ + row, + onBokfor, + onMatch, +}: { + row: SkattekontoTransactionWithSuggestion + onBokfor?: (row: StoredSkattekontoTransaction) => void + onMatch?: (row: StoredSkattekontoTransaction) => void +}) { + const amount = Number(row.belopp_skatteverket) + const isIncome = amount > 0 + const isBooked = !!row.journal_entry_id + return ( + + +
+
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{row.transaktionstext}

+
+ {formatDate(row.transaktionsdatum)} + · + + + Skatteverket + + {isBooked ? ( + <> + · + + + Bokförd + + + ) : row.match_suggestion ? ( + <> + · + + Möjlig dublett + + + ) : ( + <> + · + Ej bokförd + + )} +
+
+
+
+ {!isBooked && onMatch && ( + + )} + {!isBooked && !row.match_suggestion && onBokfor && ( + + )} + {isBooked && ( + + )} +
+

+ {isIncome ? '+' : ''} + {formatCurrency(amount)} +

+
+
+
+
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 0802f887..c2285468 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { cn, formatCurrency, formatDate } from '@/lib/utils' -import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react' +import { AlertCircle, ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip' import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { getTemplateById } from '@/lib/bookkeeping/booking-templates' @@ -19,6 +19,10 @@ interface TransactionInboxCardProps { transaction: TransactionWithInvoice suggestions?: SuggestedCategory[] templateSuggestions?: SuggestedTemplate[] + /** When set, this bank tx looks like the bank side of a 1930↔1630 + * transfer that the user will later see on /skattekonto. Renders a + * hint warning so the user doesn't book both sides separately. */ + skvCounterpartDate?: string processingId: string | null isBatchMode: boolean isSelected: boolean @@ -38,6 +42,7 @@ export default function TransactionInboxCard({ transaction, suggestions, templateSuggestions, + skvCounterpartDate, processingId, isBatchMode, isSelected, @@ -94,6 +99,19 @@ export default function TransactionInboxCard({ onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined} > + {skvCounterpartDate && ( +
+ +

+ Möjlig 1930↔1630-överföring.{' '} + Det finns en skattekonto-händelse den{' '} + {skvCounterpartDate} som + matchar — bokför detta verifikat först, koppla sedan + skattekonto-raden mot samma verifikat istället för att bokföra + två gånger. +

+
+ )}
{/* Left: checkbox + icon + info */}
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 484f9473..bcd66bda 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -38,6 +38,7 @@ import { import { toToolError } from './tool-result' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateGeneralLedger } from '@/lib/reports/general-ledger' +import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' @@ -4237,7 +4238,7 @@ export const tools: McpTool[] = [ // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_employees', - description: 'List employees for the active company. Personnummer returned masked (XXXXXXXX-NNNN).', + description: 'List employees for the active company. Personnummer returned masked (YYYYMMDD-XXXX).', inputSchema: { type: 'object', properties: { @@ -4257,12 +4258,12 @@ export const tools: McpTool[] = [ const activeOnly = args.active_only !== false let query = supabase .from('employees') - .select('id, first_name, last_name, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active') + .select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active') .eq('company_id', companyId) if (activeOnly) query = query.eq('is_active', true) const { data, error } = await query.order('last_name') if (error) throw new Error(`Database error: ${error.message}`) - const employees = (data || []).map(e => ({ ...e, personnummer: `XXXXXXXX-${e.personnummer_last4}` })) + const employees = (data || []).map(e => ({ ...e, personnummer: maskPersonnummer(decryptPersonnummer(e.personnummer as string)) })) return { employees, count: employees.length } }, }, @@ -4289,9 +4290,9 @@ export const tools: McpTool[] = [ if (error || !run) throw new Error('Salary run not found') const { data: employees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer_last4)') + .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4)') .eq('salary_run_id', id) - return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record), personnummer: `XXXXXXXX-${(e.employee as Record).personnummer_last4}` } : null })) } + return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record).personnummer as string)) } : null })) } }, }, { diff --git a/extensions/general/skatteverket/__tests__/mappers.test.ts b/extensions/general/skatteverket/__tests__/mappers.test.ts index 99b2ee44..3d54ca74 100644 --- a/extensions/general/skatteverket/__tests__/mappers.test.ts +++ b/extensions/general/skatteverket/__tests__/mappers.test.ts @@ -94,6 +94,57 @@ describe('rutorToMomsuppgift', () => { expect(result.import).toBe(2000) expect(result.momsImportUtgaendeHog).toBe(500) }) + + // FK009 regression: SKV recomputes summaMoms from rounded rutor and + // compares against ours. If we round ruta49 from unrounded inputs while + // rounding each ruta independently we drift by ±1 SEK per fractional ruta. + it('summaMoms equals Σ(rounded output VAT rutor) - rounded ruta48', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + // Fractional öres on every VAT-amount ruta so banker's rounding can + // disagree between the orundad ruta49 and the rundade individual fält. + ruta10: 100.49, + ruta11: 50.51, + ruta12: 25.49, + ruta30: 10.51, + ruta31: 5.49, + ruta32: 2.51, + ruta60: 7.49, + ruta61: 3.51, + ruta62: 1.49, + ruta48: 80.51, + ruta49: 100.49 + 50.51 + 25.49 + 10.51 + 5.49 + 2.51 + 7.49 + 3.51 + 1.49 - 80.51, + } + + const result = rutorToMomsuppgift(rutor) + + const expectedSumma = + (result.momsForsaljningUtgaendeHog ?? 0) + + (result.momsForsaljningUtgaendeMedel ?? 0) + + (result.momsForsaljningUtgaendeLag ?? 0) + + (result.momsInkopUtgaendeHog ?? 0) + + (result.momsInkopUtgaendeMedel ?? 0) + + (result.momsInkopUtgaendeLag ?? 0) + + (result.momsImportUtgaendeHog ?? 0) + + (result.momsImportUtgaendeMedel ?? 0) + + (result.momsImportUtgaendeLag ?? 0) - + (result.ingaendeMomsAvdrag ?? 0) + + expect(result.summaMoms).toBe(expectedSumma) + // Sanity-check: result is an integer (SKV requires whole kronor) + expect(Number.isInteger(result.summaMoms)).toBe(true) + }) + + it('summaMoms is negative when input VAT exceeds output VAT', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta48: 5000, + ruta49: -5000, + } + + const result = rutorToMomsuppgift(rutor) + expect(result.summaMoms).toBe(-5000) + }) }) describe('formatRedovisare', () => { diff --git a/extensions/general/skatteverket/__tests__/skattekonto-match.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-match.test.ts new file mode 100644 index 00000000..1890fac0 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-match.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, vi } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { + findMatchCandidates, + findMatchSuggestionsBulk, + matchSkattekontoToEntry, + SkattekontoMatchError, +} from '../lib/skattekonto-match' + +const COMPANY = 'company-1' +const TX_ID = 'skv-tx-1' + +function txRow(overrides: Record = {}) { + return { + id: TX_ID, + company_id: COMPANY, + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + transaktionstext: 'Inbetalning bokförd', + status: 'booked', + ...overrides, + } +} + +function lineRow(opts: { + entryId: string + debit?: number + credit?: number + voucherNumber?: number | null + entryDate?: string + description?: string + status?: 'draft' | 'posted' | 'reversed' +}) { + return { + debit_amount: opts.debit ?? 0, + credit_amount: opts.credit ?? 0, + journal_entries: { + id: opts.entryId, + voucher_number: opts.voucherNumber ?? 12, + voucher_series: 'A', + entry_date: opts.entryDate ?? '2026-03-16', + description: opts.description ?? 'Test verifikat', + status: opts.status ?? 'posted', + company_id: COMPANY, + }, + } +} + +// ────────────────────────────────────────────────────────────────────── +// findMatchCandidates +// ────────────────────────────────────────────────────────────────────── + +describe('findMatchCandidates', () => { + it('returns candidate verifikat that debits 1630 with matching amount (positive SKV → looks for debit on 1630)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ data: [lineRow({ entryId: 'je-1', debit: 5000, credit: 0 })] }) + enqueue({ data: [] }) // no already-linked + + const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + journal_entry_id: 'je-1', + matched_amount: 5000, + matched_side: 'debit', + }) + }) + + it('uses credit 1630 lookup when SKV amount is negative (money leaving skattekontot)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow({ belopp_skatteverket: -8333, transaktionstext: 'Debiterad F-skatt' }) }) + enqueue({ data: [lineRow({ entryId: 'je-7', debit: 0, credit: 8333 })] }) + enqueue({ data: [] }) + + const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0].matched_side).toBe('credit') + expect(result.candidates[0].matched_amount).toBe(8333) + }) + + it('throws TRANSACTION_NOT_FOUND when the SKV row does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'not found' } }) + + await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toMatchObject({ + code: 'TRANSACTION_NOT_FOUND', + }) + }) + + it('throws ALREADY_BOOKED when the SKV row is already linked', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow({ journal_entry_id: 'je-existing' }) }) + + await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toMatchObject({ + code: 'ALREADY_BOOKED', + }) + }) + + it('filters out entries already linked to another SKV row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ + data: [ + lineRow({ entryId: 'je-1', debit: 5000, credit: 0 }), + lineRow({ entryId: 'je-2', debit: 5000, credit: 0 }), + ], + }) + enqueue({ data: [{ journal_entry_id: 'je-1' }] }) // je-1 already linked + + const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID) + expect(result.candidates.map(c => c.journal_entry_id)).toEqual(['je-2']) + }) + + it('returns an empty list when no candidate lines match', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ data: [] }) // no candidate lines + + const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID) + expect(result.candidates).toEqual([]) + }) + + it('throws a SkattekontoMatchError (not a plain Error) so callers can switch on code', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow({ journal_entry_id: 'je-existing' }) }) + + await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toBeInstanceOf( + SkattekontoMatchError, + ) + }) + + it('orders candidates by date proximity to the SKV row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow({ transaktionsdatum: '2026-03-17' }) }) + enqueue({ + data: [ + lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-03-08' }), // 9 days + lineRow({ entryId: 'je-close', debit: 5000, entryDate: '2026-03-16' }), // 1 day + lineRow({ entryId: 'je-mid', debit: 5000, entryDate: '2026-03-12' }), // 5 days + ], + }) + enqueue({ data: [] }) + + const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID) + expect(result.candidates.map(c => c.journal_entry_id)).toEqual([ + 'je-close', + 'je-mid', + 'je-far', + ]) + }) +}) + +// ────────────────────────────────────────────────────────────────────── +// matchSkattekontoToEntry +// ────────────────────────────────────────────────────────────────────── + +describe('matchSkattekontoToEntry', () => { + it('writes the journal_entry_id when the candidate has a valid 1630 line', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ + data: { + id: 'je-1', + status: 'posted', + lines: [ + { account_number: '1630', debit_amount: 5000, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 5000 }, + ], + }, + }) + enqueue({ data: null }) // not already linked + enqueue({ data: null }) // update result + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).resolves.toBeUndefined() + }) + + it('throws TRANSACTION_NOT_FOUND when the SKV row is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'not found' } }) + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).rejects.toMatchObject({ code: 'TRANSACTION_NOT_FOUND' }) + }) + + it('throws ALREADY_BOOKED when the SKV row already has a journal_entry_id', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow({ journal_entry_id: 'je-other' }) }) + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).rejects.toMatchObject({ code: 'ALREADY_BOOKED' }) + }) + + it('throws ENTRY_NOT_FOUND when the candidate verifikat does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ data: null, error: { message: 'not found' } }) + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-missing'), + ).rejects.toMatchObject({ code: 'ENTRY_NOT_FOUND' }) + }) + + it('throws INVALID_CANDIDATE when the verifikat has no 1630 line matching amount + side', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) // expects debit 5000 on 1630 + enqueue({ + data: { + id: 'je-1', + status: 'posted', + lines: [ + // Wrong side: credit 5000 on 1630 (doesn't match a positive SKV) + { account_number: '1630', debit_amount: 0, credit_amount: 5000 }, + ], + }, + }) + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' }) + }) + + it('throws INVALID_CANDIDATE when the verifikat is reversed (makulerat)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ + data: { + id: 'je-1', + status: 'reversed', + lines: [{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }], + }, + }) + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' }) + }) + + it('throws ENTRY_ALREADY_LINKED when another SKV row is already linked to this verifikat', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ + data: { + id: 'je-1', + status: 'posted', + lines: [{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }], + }, + }) + enqueue({ data: { id: 'skv-other' } }) // already linked + + await expect( + matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'), + ).rejects.toMatchObject({ code: 'ENTRY_ALREADY_LINKED' }) + }) +}) + +// ────────────────────────────────────────────────────────────────────── +// findMatchSuggestionsBulk +// ────────────────────────────────────────────────────────────────────── + +describe('findMatchSuggestionsBulk', () => { + it('returns a suggestion only when exactly one candidate matches per row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + lineRow({ entryId: 'je-unique', debit: 5000, entryDate: '2026-03-16' }), + // unrelated different-amount line that should not match + lineRow({ entryId: 'je-other', debit: 9999, entryDate: '2026-03-16' }), + ], + }) + enqueue({ data: [] }) // none linked + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + }, + ]) + + expect(suggestions.size).toBe(1) + expect(suggestions.get('skv-1')).toMatchObject({ journal_entry_id: 'je-unique' }) + }) + + it('returns no suggestion when there are TWO candidates (ambiguous)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + lineRow({ entryId: 'je-a', debit: 5000, entryDate: '2026-03-15' }), + lineRow({ entryId: 'je-b', debit: 5000, entryDate: '2026-03-16' }), + ], + }) + enqueue({ data: [] }) + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('returns no suggestion when zero candidates match', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [] }) + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('skips rows that are already linked to a verifikat', async () => { + // already-linked rows shouldn't even reach the candidate query — but + // verify by passing no other unmatched rows; the queue stays empty. + const { supabase } = createQueuedMockSupabase() + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: 'je-existing', + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('skips candidates whose entry_date is outside the per-row ±14 day window', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + // 20 days before the SKV row — too far + lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-02-25' }), + ], + }) + enqueue({ data: [] }) + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('excludes entries that are already linked to a different SKV row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [lineRow({ entryId: 'je-linked', debit: 5000, entryDate: '2026-03-16' })], + }) + enqueue({ data: [{ journal_entry_id: 'je-linked' }] }) + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: 5000, + journal_entry_id: null, + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('respects sign convention per row: negative SKV needs a credit on 1630', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + // Debit-side line — wrong side for a -8333 SKV row + lineRow({ entryId: 'je-wrong-side', debit: 8333, entryDate: '2026-03-16' }), + ], + }) + enqueue({ data: [] }) + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + { + id: 'skv-1', + transaktionsdatum: '2026-03-17', + belopp_skatteverket: -8333, + journal_entry_id: null, + }, + ]) + expect(suggestions.size).toBe(0) + }) + + it('returns empty map immediately when no unmatched rows are provided', async () => { + // No queue interaction expected — function should short-circuit. + const { supabase } = createQueuedMockSupabase() + const fromSpy = vi.spyOn(supabase, 'from') + + const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, []) + + expect(suggestions.size).toBe(0) + expect(fromSpy).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index f22f67ba..0f896d3b 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -20,6 +20,12 @@ import { } from './lib/agi-client' import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync' import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking' +import { + findMatchCandidates, + findMatchSuggestionsBulk, + matchSkattekontoToEntry, + SkattekontoMatchError, +} from './lib/skattekonto-match' import type { SkattekontoBalanceSnapshot } from './types' import type { VatPeriodType } from '@/types' @@ -1380,10 +1386,32 @@ export const skatteverketExtension: Extension = { return NextResponse.json({ error: error.message }, { status: 500 }) } + const rows = data ?? [] + const booked = rows.filter(r => r.status === 'booked') + + // Enrich obokförda rader with a single-best-candidate suggestion. + // Only attached when there's exactly one match — avoids the UI + // confidently pointing at the wrong verifikat. + const suggestions = await findMatchSuggestionsBulk( + ctx.supabase, + ctx.companyId, + booked.map(r => ({ + id: r.id, + transaktionsdatum: r.transaktionsdatum, + belopp_skatteverket: Number(r.belopp_skatteverket), + journal_entry_id: r.journal_entry_id, + })), + ) + + const bookedEnriched = booked.map(r => ({ + ...r, + match_suggestion: suggestions.get(r.id) ?? null, + })) + return NextResponse.json({ data: { - booked: (data ?? []).filter(r => r.status === 'booked'), - upcoming: (data ?? []).filter(r => r.status === 'upcoming'), + booked: bookedEnriched, + upcoming: rows.filter(r => r.status === 'upcoming'), }, }) }, @@ -1452,6 +1480,90 @@ export const skatteverketExtension: Extension = { } }, }, + + // ── Matcha mot befintligt verifikat ────────────────────────────── + // List candidate journal entries already touching 1630 with the right + // amount/side near the transaction date. Lets the user link the SKV + // row to a manually-booked bank transfer instead of creating a duplicate + // verifikat. Returns at most 25 candidates. + { + method: 'GET', + path: '/skattekonto/transaktioner/:id/match-candidates', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + const url = new URL(request.url) + const id = url.searchParams.get('_id') + if (!id) { + return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 }) + } + try { + const { candidates } = await findMatchCandidates(ctx.supabase, ctx.companyId, id) + return NextResponse.json({ data: { candidates } }) + } catch (err) { + if (err instanceof SkattekontoMatchError) { + const status = + err.code === 'TRANSACTION_NOT_FOUND' ? 404 + : err.code === 'ALREADY_BOOKED' ? 409 + : 400 + return NextResponse.json({ error: err.message, code: err.code }, { status }) + } + return handleSkvError(err) + } + }, + }, + + // Link the SKV row to a chosen candidate. No new verifikat is created + // — we just write journal_entry_id onto skattekonto_transactions. The + // candidate is re-validated server-side (matching 1630 line, not already + // linked) to catch races and a malicious client. + { + method: 'POST', + path: '/skattekonto/transaktioner/:id/match', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + const url = new URL(request.url) + const id = url.searchParams.get('_id') + if (!id) { + return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 }) + } + let body: { journal_entry_id?: string } + try { + body = (await request.json()) as { journal_entry_id?: string } + } catch { + return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 }) + } + if (!body.journal_entry_id || typeof body.journal_entry_id !== 'string') { + return NextResponse.json( + { error: 'Saknar journal_entry_id' }, + { status: 400 }, + ) + } + try { + await matchSkattekontoToEntry( + ctx.supabase, + ctx.companyId, + id, + body.journal_entry_id, + ) + return NextResponse.json({ data: { ok: true } }) + } catch (err) { + if (err instanceof SkattekontoMatchError) { + const status = + err.code === 'TRANSACTION_NOT_FOUND' ? 404 + : err.code === 'ENTRY_NOT_FOUND' ? 404 + : err.code === 'ALREADY_BOOKED' ? 409 + : err.code === 'ENTRY_ALREADY_LINKED' ? 409 + : 422 + return NextResponse.json({ error: err.message, code: err.code }, { status }) + } + return handleSkvError(err) + } + }, + }, ], } diff --git a/extensions/general/skatteverket/lib/mappers.ts b/extensions/general/skatteverket/lib/mappers.ts index 02c4a049..758928f2 100644 --- a/extensions/general/skatteverket/lib/mappers.ts +++ b/extensions/general/skatteverket/lib/mappers.ts @@ -9,6 +9,14 @@ export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/f * * Fields with value 0 are omitted (Skatteverket treats absent fields as 0). * This keeps the payload clean and avoids sending unnecessary data. + * + * Rounding: every ruta is rounded to whole kronor (SKV's schema is integers). + * summaMoms is recomputed from the rounded VAT-amount rutor — not from the + * pre-rounding ruta49 — so the payload is internally consistent with how + * Skatteverket recomputes the sum on their side. Rounding ruta49 separately + * from the components causes ±1 SEK drift on fractional-öres inputs and + * triggers SKV's FK009 ("summaMoms stämmer inte överens med övriga + * momsuppgifter") even when the underlying ledger arithmetic is correct. */ export function rutorToMomsuppgift(rutor: VatDeclarationRutor): SkatteverketMomsuppgift { const result: SkatteverketMomsuppgift = {} @@ -54,15 +62,27 @@ export function rutorToMomsuppgift(rutor: VatDeclarationRutor): SkatteverketMoms // Input VAT set('ingaendeMomsAvdrag', rutor.ruta48) - // Net VAT (must always be present, whole kronor) - result.summaMoms = Math.round(rutor.ruta49) - // Import set('import', rutor.ruta50) set('momsImportUtgaendeHog', rutor.ruta60) set('momsImportUtgaendeMedel', rutor.ruta61) set('momsImportUtgaendeLag', rutor.ruta62) + // Net VAT must always be present, whole kronor. Compute from the already- + // rounded VAT-amount rutor so SKV's reconciliation (sum of integer rutor) + // never disagrees with our summaMoms by ±1 SEK. + result.summaMoms = + (result.momsForsaljningUtgaendeHog ?? 0) + + (result.momsForsaljningUtgaendeMedel ?? 0) + + (result.momsForsaljningUtgaendeLag ?? 0) + + (result.momsInkopUtgaendeHog ?? 0) + + (result.momsInkopUtgaendeMedel ?? 0) + + (result.momsInkopUtgaendeLag ?? 0) + + (result.momsImportUtgaendeHog ?? 0) + + (result.momsImportUtgaendeMedel ?? 0) + + (result.momsImportUtgaendeLag ?? 0) - + (result.ingaendeMomsAvdrag ?? 0) + return result } diff --git a/extensions/general/skatteverket/lib/skattekonto-match.ts b/extensions/general/skatteverket/lib/skattekonto-match.ts new file mode 100644 index 00000000..d90ec862 --- /dev/null +++ b/extensions/general/skatteverket/lib/skattekonto-match.ts @@ -0,0 +1,440 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { StoredSkattekontoTransaction } from '../types' + +/** + * "Matcha mot befintligt verifikat"-flöde för skattekonto-rader. + * + * Jacob's use case: + * 16/3: User books a manual transfer (D 1630 / C 1930, X kr) when they + * pay preliminärskatt from the bank. + * 17/3: Skatteverket reports the same payment landing on skattekontot. + * + * Without matching, the per-row Bokför button would create a *second* + * verifikat with the same 1630-leg → double-counted cash flow. This module + * finds the existing entry and links the SKV row to it, no new draft. + * + * The candidate query is intentionally strict (exact amount, exact side, + * unused entry) — false positives would be silently destructive. False + * negatives just fall back to "Bokför / Skapa manuellt". + */ + +const SKATTEKONTO_ACCOUNT = '1630' +const DATE_WINDOW_DAYS = 14 + +export class SkattekontoMatchError extends Error { + constructor( + message: string, + public readonly code: + | 'TRANSACTION_NOT_FOUND' + | 'ALREADY_BOOKED' + | 'ENTRY_NOT_FOUND' + | 'ENTRY_ALREADY_LINKED' + | 'INVALID_CANDIDATE', + ) { + super(message) + this.name = 'SkattekontoMatchError' + } +} + +export interface SkattekontoMatchCandidate { + journal_entry_id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' + matched_amount: number + matched_side: 'debit' | 'credit' +} + +/** + * Bulk-enrich a list of unmatched SKV rows with a `match_suggestion` field + * pointing to a "high confidence" candidate verifikat. We only attach the + * suggestion when there is EXACTLY ONE candidate — multiple matches means + * we can't auto-suggest without risking the wrong link. The user can still + * open the full Matcha-dialog manually in that case. + * + * Done in a single SQL pass to keep listing performance reasonable: + * fetch all 1630-lines for entries in the widest possible date window + * covering all rows, then match in-memory. + */ +export async function findMatchSuggestionsBulk( + supabase: SupabaseClient, + companyId: string, + rows: Array<{ + id: string + transaktionsdatum: string + belopp_skatteverket: number + journal_entry_id: string | null + }>, +): Promise> { + const unmatched = rows.filter(r => !r.journal_entry_id) + if (unmatched.length === 0) return new Map() + + const dates = unmatched.map(r => r.transaktionsdatum).sort() + const from = addDays(dates[0], -DATE_WINDOW_DAYS) + const to = addDays(dates[dates.length - 1], DATE_WINDOW_DAYS) + + const { data, error } = await supabase + .from('journal_entry_lines') + .select( + ` + debit_amount, + credit_amount, + journal_entries!inner ( + id, + voucher_number, + voucher_series, + entry_date, + description, + status, + company_id + ) + `, + ) + .eq('account_number', SKATTEKONTO_ACCOUNT) + .eq('journal_entries.company_id', companyId) + .gte('journal_entries.entry_date', from) + .lte('journal_entries.entry_date', to) + .neq('journal_entries.status', 'reversed') + + if (error || !data) return new Map() + + type Row = { + debit_amount: number + credit_amount: number + journal_entries: { + id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' + company_id: string + } + } + const lines = data as unknown as Row[] + + // Filter out entries already linked to another SKV row. + const candidateEntryIds = Array.from(new Set(lines.map(l => l.journal_entries.id))) + const { data: linked } = candidateEntryIds.length + ? await supabase + .from('skattekonto_transactions') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', candidateEntryIds) + : { data: [] } + + const linkedSet = new Set( + (linked ?? []) + .map((l: { journal_entry_id: string | null }) => l.journal_entry_id) + .filter((id): id is string => !!id), + ) + + const suggestions = new Map() + + for (const row of unmatched) { + const amount = Math.round(Math.abs(Number(row.belopp_skatteverket)) * 100) / 100 + const side = expectedSide(Number(row.belopp_skatteverket)) + const rowFrom = addDays(row.transaktionsdatum, -DATE_WINDOW_DAYS) + const rowTo = addDays(row.transaktionsdatum, DATE_WINDOW_DAYS) + + const matches: SkattekontoMatchCandidate[] = [] + const seen = new Set() + + for (const line of lines) { + const e = line.journal_entries + if (linkedSet.has(e.id)) continue + if (seen.has(e.id)) continue + if (e.entry_date < rowFrom || e.entry_date > rowTo) continue + + const debit = Math.round(Number(line.debit_amount) * 100) / 100 + const credit = Math.round(Number(line.credit_amount) * 100) / 100 + const lineMatches = + side === 'debit' + ? debit === amount && credit === 0 + : credit === amount && debit === 0 + if (!lineMatches) continue + + seen.add(e.id) + matches.push({ + journal_entry_id: e.id, + voucher_number: e.voucher_number, + voucher_series: e.voucher_series, + entry_date: e.entry_date, + description: e.description, + status: e.status, + matched_amount: amount, + matched_side: side, + }) + + if (matches.length > 1) break + } + + // Auto-suggest only when there's a single unambiguous match. + if (matches.length === 1) { + suggestions.set(row.id, matches[0]) + } + } + + return suggestions +} + +function addDays(iso: string, days: number): string { + const d = new Date(iso + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +function expectedSide(beloppSkatteverket: number): 'debit' | 'credit' { + // Positive SKV amount = money INTO skattekonto = 1630 increases = DEBIT 1630 + // Negative SKV amount = money OUT of skattekonto = 1630 decreases = CREDIT 1630 + return beloppSkatteverket > 0 ? 'debit' : 'credit' +} + +/** + * Find existing journal entries that look like the bank side of this + * skattekonto row. + * + * Returns up to 25 candidates ordered by date proximity to the SKV row. + */ +export async function findMatchCandidates( + supabase: SupabaseClient, + companyId: string, + transactionId: string, +): Promise<{ tx: StoredSkattekontoTransaction; candidates: SkattekontoMatchCandidate[] }> { + const { data: tx, error: txError } = await supabase + .from('skattekonto_transactions') + .select('*') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (txError || !tx) { + throw new SkattekontoMatchError( + 'Skattekonto-transaktionen hittades inte.', + 'TRANSACTION_NOT_FOUND', + ) + } + + if (tx.journal_entry_id) { + throw new SkattekontoMatchError( + 'Transaktionen är redan kopplad till ett verifikat.', + 'ALREADY_BOOKED', + ) + } + + const amount = Math.round(Math.abs(Number(tx.belopp_skatteverket)) * 100) / 100 + const side = expectedSide(Number(tx.belopp_skatteverket)) + const from = addDays(tx.transaktionsdatum, -DATE_WINDOW_DAYS) + const to = addDays(tx.transaktionsdatum, DATE_WINDOW_DAYS) + + // Query 1630-lines with the right amount + side, joined to entries in + // the date window. `!inner` filters out rows whose joined entry doesn't + // match (Supabase pg-rest convention). + let q = supabase + .from('journal_entry_lines') + .select( + ` + debit_amount, + credit_amount, + journal_entries!inner ( + id, + voucher_number, + voucher_series, + entry_date, + description, + status, + company_id + ) + `, + ) + .eq('account_number', SKATTEKONTO_ACCOUNT) + .eq('journal_entries.company_id', companyId) + .gte('journal_entries.entry_date', from) + .lte('journal_entries.entry_date', to) + .neq('journal_entries.status', 'reversed') + + if (side === 'debit') { + q = q.eq('debit_amount', amount).eq('credit_amount', 0) + } else { + q = q.eq('credit_amount', amount).eq('debit_amount', 0) + } + + const { data: rows, error: rowsError } = await q.limit(50) + if (rowsError) { + throw new Error(`Kunde inte söka kandidater: ${rowsError.message}`) + } + + type Row = { + debit_amount: number + credit_amount: number + journal_entries: { + id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' + company_id: string + } + } + const typedRows = (rows ?? []) as unknown as Row[] + + if (typedRows.length === 0) { + return { tx, candidates: [] } + } + + // Filter out entries already linked to another skattekonto_transactions + // row — those represent payments we've already accounted for. + const candidateEntryIds = Array.from(new Set(typedRows.map(r => r.journal_entries.id))) + const { data: linked } = await supabase + .from('skattekonto_transactions') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', candidateEntryIds) + + const linkedSet = new Set( + (linked ?? []) + .map((l: { journal_entry_id: string | null }) => l.journal_entry_id) + .filter((id): id is string => !!id), + ) + + const seen = new Set() + const candidates: SkattekontoMatchCandidate[] = [] + for (const row of typedRows) { + const e = row.journal_entries + if (linkedSet.has(e.id)) continue + if (seen.has(e.id)) continue + seen.add(e.id) + candidates.push({ + journal_entry_id: e.id, + voucher_number: e.voucher_number, + voucher_series: e.voucher_series, + entry_date: e.entry_date, + description: e.description, + status: e.status, + matched_amount: amount, + matched_side: side, + }) + } + + // Order by date proximity to the SKV row, then by voucher number desc. + const target = new Date(tx.transaktionsdatum + 'T00:00:00Z').getTime() + candidates.sort((a, b) => { + const da = Math.abs(new Date(a.entry_date + 'T00:00:00Z').getTime() - target) + const db = Math.abs(new Date(b.entry_date + 'T00:00:00Z').getTime() - target) + if (da !== db) return da - db + return (b.voucher_number ?? 0) - (a.voucher_number ?? 0) + }) + + return { tx, candidates: candidates.slice(0, 25) } +} + +/** + * Link a skattekonto_transactions row to an existing journal entry. + * + * Re-validates the candidate server-side: the entry must still belong to + * the company, still have a 1630-line on the expected side with the + * expected amount, and must not have been linked in the meantime. + */ +export async function matchSkattekontoToEntry( + supabase: SupabaseClient, + companyId: string, + transactionId: string, + journalEntryId: string, +): Promise { + const { data: tx, error: txError } = await supabase + .from('skattekonto_transactions') + .select('*') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (txError || !tx) { + throw new SkattekontoMatchError( + 'Skattekonto-transaktionen hittades inte.', + 'TRANSACTION_NOT_FOUND', + ) + } + if (tx.journal_entry_id) { + throw new SkattekontoMatchError( + 'Transaktionen är redan kopplad till ett verifikat.', + 'ALREADY_BOOKED', + ) + } + + const { data: entry, error: entryError } = await supabase + .from('journal_entries') + .select( + ` + id, + status, + lines:journal_entry_lines ( + account_number, + debit_amount, + credit_amount + ) + `, + ) + .eq('id', journalEntryId) + .eq('company_id', companyId) + .single() + + if (entryError || !entry) { + throw new SkattekontoMatchError( + 'Verifikatet hittades inte.', + 'ENTRY_NOT_FOUND', + ) + } + if (entry.status === 'reversed') { + throw new SkattekontoMatchError( + 'Verifikatet är makulerat och kan inte matchas.', + 'INVALID_CANDIDATE', + ) + } + + const amount = Math.round(Math.abs(Number(tx.belopp_skatteverket)) * 100) / 100 + const side = expectedSide(Number(tx.belopp_skatteverket)) + type Line = { account_number: string; debit_amount: number; credit_amount: number } + const hasMatchingLine = (entry.lines as Line[] | null)?.some(l => { + if (l.account_number !== SKATTEKONTO_ACCOUNT) return false + const debit = Math.round(Number(l.debit_amount) * 100) / 100 + const credit = Math.round(Number(l.credit_amount) * 100) / 100 + return side === 'debit' + ? debit === amount && credit === 0 + : credit === amount && debit === 0 + }) + + if (!hasMatchingLine) { + throw new SkattekontoMatchError( + 'Verifikatet saknar en matchande rad på 1630.', + 'INVALID_CANDIDATE', + ) + } + + const { data: alreadyLinked } = await supabase + .from('skattekonto_transactions') + .select('id') + .eq('company_id', companyId) + .eq('journal_entry_id', journalEntryId) + .maybeSingle() + + if (alreadyLinked) { + throw new SkattekontoMatchError( + 'Verifikatet är redan kopplat till en annan skattekonto-transaktion.', + 'ENTRY_ALREADY_LINKED', + ) + } + + const { error: updateError } = await supabase + .from('skattekonto_transactions') + .update({ journal_entry_id: journalEntryId }) + .eq('id', transactionId) + .eq('company_id', companyId) + .is('journal_entry_id', null) // guard against concurrent updates + + if (updateError) { + throw new Error(`Kunde inte koppla transaktionen: ${updateError.message}`) + } +} diff --git a/extensions/general/skatteverket/types.ts b/extensions/general/skatteverket/types.ts index 8702108a..b14f0740 100644 --- a/extensions/general/skatteverket/types.ts +++ b/extensions/general/skatteverket/types.ts @@ -299,23 +299,14 @@ export interface SkatteverketFel { felmeddelande: string } -/** Row shape for the skattekonto_transactions table (DB → app) */ -export interface StoredSkattekontoTransaction { - id: string - company_id: string - transaktionsidentitet: number | null - dedup_key: string - transaktionsdatum: string - forfallodatum: string | null - ranteberakningsdatum: string | null - transaktionstext: string - belopp_skatteverket: number - belopp_kronofogden: number | null - status: 'booked' | 'upcoming' - journal_entry_id: string | null - imported_at: string - updated_at: string -} +// Re-exported from core because the table lives in core migrations and +// the /transactions page (core) needs to render its rows. Extension-internal +// code continues to import from this module for backwards compatibility. +export type { + StoredSkattekontoTransaction, + SkattekontoMatchSuggestion, + SkattekontoTransactionWithSuggestion, +} from '@/types/skatteverket' /** Cached snapshot stored in extension_data under key skattekonto_balance_snapshot */ export interface SkattekontoBalanceSnapshot { diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts index e313a126..4d3f2c7b 100644 --- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts @@ -55,6 +55,23 @@ vi.mock('../vat-entries', () => ({ ] } ), + generateReverseChargeBasisLines: vi.fn().mockImplementation( + (baseAmount: number, vatRate: number = 0.25, supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business') => { + if (baseAmount <= 0) return [] + const rateIdx = vatRate === 0.25 ? 0 : vatRate === 0.12 ? 1 : vatRate === 0.06 ? 2 : -1 + if (rateIdx < 0) return [] + const accounts = { + eu_business: ['4535', '4536', '4537'], + non_eu_business: ['4531', '4532', '4533'], + swedish_business: ['4425', '4426', '4427'], + }[supplierType] + const amount = Math.round(baseAmount * 100) / 100 + return [ + { account_number: accounts[rateIdx], debit_amount: amount, credit_amount: 0, line_description: `basbelopp ${vatRate * 100}%` }, + { account_number: '4598', debit_amount: 0, credit_amount: amount, line_description: `motkonto ${vatRate * 100}%` }, + ] + } + ), })) const { createJournalEntry, findFiscalPeriod } = await import('../engine') @@ -178,7 +195,7 @@ describe('createSupplierInvoiceRegistrationEntry', () => { assertBalanced(input) }) - it('creates EU reverse charge entry at 25%', async () => { + it('creates EU reverse charge entry at 25% with basbelopp on 4535 (ruta 21)', async () => { const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, @@ -204,14 +221,74 @@ describe('createSupplierInvoiceRegistrationEntry', () => { expect(credit2614).toHaveLength(1) expect(credit2614[0].credit_amount).toBe(2500) + // Basbeloppsrader för ruta 21 (EU tjänster huvudregeln) — utan dessa + // avvisar Skatteverket deklarationen med FK004. + const debit4535 = findByAccount(input.lines, '4535') + expect(debit4535).toHaveLength(1) + expect(debit4535[0].debit_amount).toBe(10000) + + const credit4598 = findByAccount(input.lines, '4598') + expect(credit4598).toHaveLength(1) + expect(credit4598[0].credit_amount).toBe(10000) + const credit2440 = findByAccount(input.lines, '2440') - // 2440 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000 - // The fiktiv moms (D 2645 / C 2614) are offsetting; 2440 only reflects actual supplier debt + // 2440 = totalDebits - totalCredits. + // Debit: 6540 (10 000) + 2645 (2 500) + 4535 (10 000) = 22 500 + // Credit: 2614 (2 500) + 4598 (10 000) = 12 500 + // 2440 = 22 500 - 12 500 = 10 000 (faktisk leverantörsskuld) expect(credit2440[0].credit_amount).toBe(10000) assertBalanced(input) }) + it('books non-EU services to 4531 (ruta 22) and motkonto 4598', async () => { + const invoice = makeSupplierInvoice({ + subtotal: 8000, + vat_amount: 0, + total: 8000, + reverse_charge: true, + }) + const items = [makeItem({ line_total: 8000, account_number: '6540', vat_rate: 0.25 })] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'company-1', 'user-1', invoice, items, 'non_eu_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + + expect(findByAccount(input.lines, '4531')[0].debit_amount).toBe(8000) + expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(8000) + // No EU-services account when supplier is non-EU + expect(findByAccount(input.lines, '4535')).toHaveLength(0) + + assertBalanced(input) + }) + + it('books domestic RC services to 4425 (ruta 24) and motkonto 4598', async () => { + const invoice = makeSupplierInvoice({ + subtotal: 20000, + vat_amount: 0, + total: 20000, + reverse_charge: true, + }) + const items = [makeItem({ line_total: 20000, account_number: '4170', vat_rate: 0.25 })] + + await createSupplierInvoiceRegistrationEntry( + null as never, 'company-1', 'user-1', invoice, items, 'swedish_business' + ) + + const input = mockedCreateEntry.mock.calls[0][3] + + expect(findByAccount(input.lines, '4425')[0].debit_amount).toBe(20000) + expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(20000) + // Domestic RC uses 2647, not 2645 + expect(findByAccount(input.lines, '2647')[0].debit_amount).toBe(5000) + expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(5000) + expect(findByAccount(input.lines, '4535')).toHaveLength(0) + + assertBalanced(input) + }) + it('creates EU reverse charge entry at reduced 12%', async () => { const invoice = makeSupplierInvoice({ subtotal: 5000, @@ -234,6 +311,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => { expect(credit2624).toHaveLength(1) expect(credit2624[0].credit_amount).toBe(600) + // 12%-raden går till 4536 (EU tjänster 12%) + expect(findByAccount(input.lines, '4536')[0].debit_amount).toBe(5000) + expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(5000) + assertBalanced(input) }) @@ -451,6 +532,13 @@ describe('createSupplierInvoiceRegistrationEntry', () => { // No regular input VAT expect(findByAccount(input.lines, '2641')).toHaveLength(0) + // User picked 4425 directly as the expense account, so the engine must + // not add parallel basbeloppsrader on 4425/4598 — that would double the + // basis. Exactly one 4425 line (the user's expense) and zero 4598. + expect(findByAccount(input.lines, '4425')).toHaveLength(1) + expect(findByAccount(input.lines, '4425')[0].debit_amount).toBe(20000) + expect(findByAccount(input.lines, '4598')).toHaveLength(0) + // 2440 = expense only (RC is offsetting) const credit2440 = findByAccount(input.lines, '2440') expect(credit2440[0].credit_amount).toBe(20000) @@ -911,7 +999,7 @@ describe('createSupplierCreditNoteEntry', () => { assertBalanced(input) }) - it('EU reverse charge reversal (C 2645, D 2614)', async () => { + it('EU reverse charge reversal (C 2645, D 2614, reverses 4535/4598 basis)', async () => { const creditNote = makeSupplierInvoice({ is_credit_note: true, subtotal: -10000, @@ -939,8 +1027,19 @@ describe('createSupplierCreditNoteEntry', () => { const credit6540 = findByAccount(input.lines, '6540')[0] expect(credit6540.credit_amount).toBe(10000) + // Reverserade basbeloppsrader: 4535 ska krediteras och 4598 debiteras med + // samma belopp så att kreditfakturan nollställer ruta 21 från originalet. + const credit4535 = findByAccount(input.lines, '4535')[0] + expect(credit4535.credit_amount).toBe(10000) + expect(credit4535.debit_amount).toBe(0) + + const debit4598 = findByAccount(input.lines, '4598')[0] + expect(debit4598.debit_amount).toBe(10000) + expect(debit4598.credit_amount).toBe(0) + const debit2440 = findByAccount(input.lines, '2440')[0] - expect(debit2440.debit_amount).toBe(10000) // totalCredits - totalDebits = (2500 + 10000) - 2500 + // totalCredits - totalDebits = (2500 + 10000 + 10000) - (2500 + 10000) = 10000 + expect(debit2440.debit_amount).toBe(10000) assertBalanced(input) }) diff --git a/lib/bookkeeping/bas-data/class-4-purchases.ts b/lib/bookkeeping/bas-data/class-4-purchases.ts index 3bbe8d0a..d713da5d 100644 --- a/lib/bookkeeping/bas-data/class-4-purchases.ts +++ b/lib/bookkeeping/bas-data/class-4-purchases.ts @@ -573,6 +573,17 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ sru_code: '7320', k2_excluded: false, }, + { + account_number: '4598', + account_name: 'Motkonto beräknad omvänd moms', + account_class: 4, + account_group: '45', + account_type: 'expense', + normal_balance: 'credit', + description: 'Tekniskt motkonto till beräknad omvänd skattskyldighet (4415-4427, 4515-4537). Nettar ut basbeloppet i resultatrapporten samtidigt som 45xx-konton synliggör underlaget för momsdeklarationens ruta 20-24.', + sru_code: '7320', + k2_excluded: false, + }, { account_number: '4600', account_name: 'Inköp av tjänster, underentreprenader och legoarbeten i Sverige (gruppkonto)', diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts index 4dc86ade..2f952351 100644 --- a/lib/bookkeeping/supplier-invoice-entries.ts +++ b/lib/bookkeeping/supplier-invoice-entries.ts @@ -1,6 +1,6 @@ import { createJournalEntry, findFiscalPeriod } from './engine' import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils' -import { generateReverseChargeLines } from './vat-entries' +import { generateReverseChargeLines, generateReverseChargeBasisLines } from './vat-entries' import { createLogger } from '@/lib/logger' import type { SupabaseClient } from '@supabase/supabase-js' import type { @@ -13,6 +13,29 @@ import type { const log = createLogger('supplier-invoice-entries') +/** + * Accounts that already populate momsdeklaration ruta 20-24 directly when + * debited. If the user picked one of these as the expense account on an RC + * invoice item, the engine must NOT add the parallel basbeloppsrader (those + * would double-count the basis). + */ +const RC_BASIS_ACCOUNTS = new Set([ + // ruta 20 — EU goods + '4515', '4516', '4517', + // ruta 21 — EU services + '4535', '4536', '4537', + // ruta 22 — non-EU services + '4531', '4532', '4533', + // ruta 23 — domestic goods RC + '4415', '4416', '4417', + // ruta 24 — domestic services RC + '4425', '4426', '4427', +]) + +function isBasisAccount(account: string): boolean { + return RC_BASIS_ACCOUNTS.has(account) +} + /** * Build a BFL-compliant verifikation description with event type, counterparty, and suffix. * Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat). @@ -88,11 +111,33 @@ export async function createSupplierInvoiceRegistrationEntry( if (isReverseCharge) { // Reverse charge: fiktiv moms entries per rate group // Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4 + // + // Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's + // momsdeklaration ruta 20-24 reflects the underlying purchase amount. + // Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32 + // but ruta 20-24 stay at 0, which Skatteverket rejects with felkod + // FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor). + // + // The basis-account check is done per (rate, account) bucket: if the user + // booked an item directly to a 44xx/45xx basis account at a given rate, + // that item's belopp already populates ruta 20-24 via the expense line — + // we only emit basbeloppsrader for the portion of that rate's base that + // went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to + // skip basis lines entirely under a per-invoice flag, leaving ruta 30 + // larger than ruta 21 by the 6540 portion — the exact FK004 pattern. const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate) + const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate) + const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business' for (const [rate, amount] of vatByRate) { if (rate > 0 && amount > 0) { - const rcLines = generateReverseChargeLines(amount / rate, rate, isDomesticRC) + const baseAmount = amount / rate + const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC) lines.push(...rcLines) + const nonBasisBase = nonBasisBaseByRate.get(rate) || 0 + if (nonBasisBase > 0) { + const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType) + lines.push(...basisLines) + } } } } else if (invoice.vat_amount > 0) { @@ -283,11 +328,26 @@ export async function createSupplierInvoiceCashEntry( if (isReverseCharge) { // Reverse charge: fiktiv moms entries per rate group // Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4 + // + // Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's + // momsdeklaration ruta 20-24 reflects the underlying purchase amount. + // Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32 + // but ruta 20-24 stay at 0, which Skatteverket rejects with felkod + // FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor). + // Per-rate bucketing: see registration entry above for the FK004 rationale. const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate) + const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate) + const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business' for (const [rate, amount] of vatByRate) { if (rate > 0 && amount > 0) { - const rcLines = generateReverseChargeLines(amount / rate, rate, isDomesticRC) + const baseAmount = amount / rate + const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC) lines.push(...rcLines) + const nonBasisBase = nonBasisBaseByRate.get(rate) || 0 + if (nonBasisBase > 0) { + const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType) + lines.push(...basisLines) + } } } } else if (invoice.vat_amount > 0) { @@ -379,6 +439,12 @@ export async function createSupplierCreditNoteEntry( // Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU const inputAccount = isDomesticRC ? '2647' : '2645' const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true) + const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true) + const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business' + // Only reverse basbeloppsraderna for the portion the registration would + // have emitted them — namely the non-basis-account base per rate. Items + // booked directly to 44xx/45xx had no parallel basis lines in registration + // and so are reversed only via the expense credit line above. for (const [rate, amount] of vatByRate) { if (rate > 0 && amount > 0) { // Determine the output account for this rate @@ -400,6 +466,25 @@ export async function createSupplierCreditNoteEntry( credit_amount: 0, line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`, }) + const nonBasisBase = nonBasisBaseByRate.get(rate) || 0 + if (nonBasisBase > 0) { + // Reverse the basbeloppsrader (44xx/45xx debit & 4598 credit on the + // registration entry become credits & debits here). Without this the + // credit note would only undo the VAT amounts (ruta 30-32 + 48) but + // leave ruta 20-24 still showing the original basbelopp — exactly + // the same FK004-style mismatch the registration fix prevents. + const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType) + // Swap debit/credit on every basis line so the credit note nets + // against the original registration verifikat. + for (const line of basisLines) { + lines.push({ + account_number: line.account_number, + debit_amount: line.credit_amount, + credit_amount: line.debit_amount, + line_description: line.line_description, + }) + } + } } } } else { @@ -461,3 +546,26 @@ function groupVatByRate( } return vatByRate } + +/** + * Sum, per VAT rate, the base (line_total in SEK) of items booked to + * non-basis expense accounts. Items already booked to a 44xx/45xx basis + * account populate ruta 20-24 directly via the expense line, so they must be + * excluded here to avoid double-counting in basbeloppsraderna. + */ +function groupNonBasisBaseByRate( + items: SupplierInvoiceItem[], + currency: string, + exchangeRate: number | null, + useAbsoluteValues = false +): Map { + const baseByRate = new Map() + for (const item of items) { + if (isBasisAccount(item.account_number)) continue + const rate = item.vat_rate ?? 0.25 + let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate) + if (useAbsoluteValues) itemSek = Math.abs(itemSek) + baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek) + } + return baseByRate +} diff --git a/lib/bookkeeping/vat-entries.ts b/lib/bookkeeping/vat-entries.ts index 7a0d9504..47d23aba 100644 --- a/lib/bookkeeping/vat-entries.ts +++ b/lib/bookkeeping/vat-entries.ts @@ -78,6 +78,91 @@ export function generateSalesVatLines(config: VatEntryConfig): CreateJournalEntr return lines } +/** + * Generate reverse-charge basis lines for momsdeklaration ruta 20-24. + * + * The fiktiv-moms pair (2645/26x4 or 2647/26x4) only carries the VAT amounts + * (ruta 30-32 and the offsetting part of ruta 48). The underlying basbelopp + * (vad köpet de facto kostade) must also land on the 44xx/45xx series so + * Skatteverket sees ruta 20-24 populated — ML 13 kap kräver att både underlag + * och moms redovisas. SKV avvisar deklarationer med ruta 30-32 men tom 20-24 + * (felkod FK004 "Eftersom det finns ett belopp i någon momsuppgift som avser + * utgående moms på inköp (30-32) måste det finnas ett belopp i någon av + * momsuppgifterna avseende momspliktiga inköp vid omvänd betalningsskyldighet + * (20-24)"). + * + * Användarens valda kostnadskonto (t.ex. 6540) bibehålls i resultaträkningen + * via en parallell motkonto-rad: 45xx debiteras, 4598 krediteras med samma + * belopp. Resultaträkningen påverkas inte (4598 nettar ut 45xx), men 45xx + * fångas av momsdeklarationsberäkningen för rätt ruta 20-24. + * + * Konto-mappning (BAS 2026 + swedish-vat reference §7): + * + * EU services (huvudregeln) 4535/4536/4537 → ruta 21 + * Non-EU services 4531/4532/4533 → ruta 22 + * Domestic services (byggtjänster) 4425/4426/4427 → ruta 24 + * Domestic goods (RC varor) 4415/4416/4417 → ruta 23 + * + * EU-varor (ruta 20, 4515/4516/4517) hanteras inte här eftersom våra supplier + * invoices saknar varor/tjänster-diskriminering. Standard-supplier-flödet är + * tjänster (SaaS, konsulttjänster); EU-varuhandel sker normalt via SIE-import + * eller manuell verifikation och får bokas direkt på 4515-konton. + */ +export function generateReverseChargeBasisLines( + baseAmount: number, + vatRate: number = 0.25, + supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business', +): CreateJournalEntryLineInput[] { + if (baseAmount <= 0) return [] + + const basisAccount = pickBasisAccount(vatRate, supplierType) + if (!basisAccount) return [] + + const amount = Math.round(baseAmount * 100) / 100 + const rateLabel = `${Math.round(vatRate * 100)}%` + + return [ + { + account_number: basisAccount.account, + debit_amount: amount, + credit_amount: 0, + line_description: `${basisAccount.label} ${rateLabel} (basbelopp omvänd skattskyldighet)`, + }, + { + account_number: '4598', + debit_amount: 0, + credit_amount: amount, + line_description: `Motkonto beräknad omvänd moms ${rateLabel}`, + }, + ] +} + +function pickBasisAccount( + vatRate: number, + supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business', +): { account: string; label: string } | null { + const rateIdx = vatRate === 0.25 ? 0 : vatRate === 0.12 ? 1 : vatRate === 0.06 ? 2 : -1 + if (rateIdx < 0) return null + + if (supplierType === 'eu_business') { + return { + account: ['4535', '4536', '4537'][rateIdx], + label: 'Inköp tjänster annat EU-land', + } + } + if (supplierType === 'non_eu_business') { + return { + account: ['4531', '4532', '4533'][rateIdx], + label: 'Inköp tjänster land utanför EU', + } + } + // swedish_business — domestic RC (byggtjänster m.m.) + return { + account: ['4425', '4426', '4427'][rateIdx], + label: 'Inköp tjänster i Sverige omvänd skattskyldighet', + } +} + /** * Generate reverse charge lines (fiktiv moms) * For EU/non-EU purchases: Debit 2645 + Credit 26x4 (offsetting entries) diff --git a/lib/reports/__tests__/vat-declaration-checks.test.ts b/lib/reports/__tests__/vat-declaration-checks.test.ts new file mode 100644 index 00000000..96649d1f --- /dev/null +++ b/lib/reports/__tests__/vat-declaration-checks.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import type { VatDeclarationRutor } from '@/types' +import { runVatDeclarationChecks } from '../vat-declaration-checks' + +const emptyRutor: VatDeclarationRutor = { + ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0, + ruta10: 0, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, + ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, + ruta48: 0, ruta49: 0, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, +} + +describe('runVatDeclarationChecks', () => { + it('returns empty findings for a balanced sales-only declaration', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta05: 100000, + ruta10: 25000, + ruta49: 25000, + } + expect(runVatDeclarationChecks(rutor)).toEqual([]) + }) + + it('returns empty findings for a balanced declaration with RC basis + output VAT', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta21: 10000, // EU services basis + ruta30: 2500, // RC output VAT + ruta48: 2500, // matching input VAT + ruta49: 0, + } + expect(runVatDeclarationChecks(rutor)).toEqual([]) + }) + + // FK004 mirror: SKV's primary rejection signal we want to catch locally. + it('flags ERROR when ruta 30-32 populated but ruta 20-24 is empty', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta05: 78852, + ruta10: 19713, + ruta30: 2500, + ruta48: 2500, + ruta49: 19713, + } + const findings = runVatDeclarationChecks(rutor) + const fk004 = findings.find((f) => f.code === 'RC_BASIS_MISSING') + expect(fk004).toBeDefined() + expect(fk004?.status).toBe('ERROR') + expect(fk004?.message).toMatch(/ruta 30-32/) + expect(fk004?.message).toMatch(/ruta 20-24/) + }) + + it('flags ERROR when basis is present but no output RC VAT', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta21: 10000, + ruta48: 2500, + ruta49: -2500, + } + const findings = runVatDeclarationChecks(rutor) + expect(findings.find((f) => f.code === 'RC_OUTPUT_MISSING')?.status).toBe('ERROR') + }) + + it('warns when input VAT is materially smaller than RC output VAT', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta21: 10000, + ruta30: 2500, + ruta48: 100, // Calculated input VAT missing — should be ~2500 + ruta49: 2400, + } + const findings = runVatDeclarationChecks(rutor) + const mismatch = findings.find((f) => f.code === 'RC_INPUT_VAT_MISMATCH') + expect(mismatch?.status).toBe('WARNING') + }) + + // FK009 detection: if our calculator and SKV's recomputed sum disagree + // we flag locally so we never submit a drift. + it('flags ERROR when ruta49 drifts from the canonical formula', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta10: 100, + ruta48: 20, + ruta49: 99, // wrong — should be 80 + } + const findings = runVatDeclarationChecks(rutor) + const drift = findings.find((f) => f.code === 'SUMMA_MOMS_DRIFT') + expect(drift?.status).toBe('ERROR') + }) + + it('ignores fractional-öre drift (≤ 0.5 SEK)', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta10: 100.30, + ruta48: 20.10, + ruta49: 80.20, // canonical formula exactly, only fractional öre + } + const findings = runVatDeclarationChecks(rutor) + expect(findings.find((f) => f.code === 'SUMMA_MOMS_DRIFT')).toBeUndefined() + }) +}) diff --git a/lib/reports/vat-declaration-checks.ts b/lib/reports/vat-declaration-checks.ts new file mode 100644 index 00000000..b0829d6d --- /dev/null +++ b/lib/reports/vat-declaration-checks.ts @@ -0,0 +1,140 @@ +import type { VatDeclarationRutor } from '@/types' + +/** + * Local pre-flight checks for the momsdeklaration, run BEFORE the SKV + * /kontrollera or /utkast calls. + * + * Why we need this: Skatteverket's "validering" only confirms that the + * payload is internally arithmetically consistent — it does NOT confirm + * that the declaration reflects reality. A declaration of all zeros + * validates fine; one with output VAT but no underlying purchases + * validates fine too, until the gateway-level FK004 rule fires. + * + * The checks below catch the patterns we have seen in practice where + * "Validera" returned OK but the declaration was wrong: + * + * - Reverse charge: ruta 30-32 populated but ruta 20-24 empty. Caused by + * supplier invoices flagged as reverse charge that booked the fiktiv + * moms (2614/2624/2634) without the parallel basis lines on 44xx/45xx. + * Fixed at the data layer by generateReverseChargeBasisLines, but we + * keep the check here as a safety net for legacy verifikat and direct + * journal entries that bypass the supplier invoice flow. + * + * - Reverse charge: ruta 20-24 populated but ruta 30-32 empty. The mirror + * case — basis booked but fiktiv moms missing. Less common but equally + * broken. + * + * - Mismatch between output RC VAT (ruta 30-32) and offsetting input VAT + * in ruta 48. The 2614/2645 (or 2647) pair must net to zero in the + * buyer's input deduction. A mismatch indicates one half of the pair + * was booked without the other. + * + * Output is consumed by the UI; ERROR findings should block "Skicka", + * WARNING findings should surface but allow the user to proceed if they + * understand the reason. + */ + +export type VatDeclarationCheckStatus = 'ERROR' | 'WARNING' + +export interface VatDeclarationCheck { + /** Stable identifier so the UI can render specific guidance per rule. */ + code: + | 'RC_BASIS_MISSING' + | 'RC_OUTPUT_MISSING' + | 'RC_INPUT_VAT_MISMATCH' + | 'SUMMA_MOMS_DRIFT' + status: VatDeclarationCheckStatus + /** Swedish user-facing message; safe to render directly in the UI. */ + message: string + /** Optional rutor that the user should investigate. */ + rutor?: Array +} + +/** + * Run all local checks against a calculated VatDeclarationRutor. + * + * Returns an empty array when the declaration looks consistent. Order + * within the returned array is stable so the UI can rely on it for + * snapshot tests. + */ +export function runVatDeclarationChecks(rutor: VatDeclarationRutor): VatDeclarationCheck[] { + const findings: VatDeclarationCheck[] = [] + + const rcOutput = rutor.ruta30 + rutor.ruta31 + rutor.ruta32 + const rcBasis = + rutor.ruta20 + rutor.ruta21 + rutor.ruta22 + rutor.ruta23 + rutor.ruta24 + + // Use a 0.5 SEK epsilon — values are rounded to öres in the calculator + // and we don't want a 0.01 rounding scrap to trip a sanity check. + const eps = 0.5 + + // FK004 mirror: output RC VAT exists, basis missing. + if (rcOutput > eps && rcBasis <= eps) { + findings.push({ + code: 'RC_BASIS_MISSING', + status: 'ERROR', + message: + 'Du har redovisat utgående moms på inköp (ruta 30-32) men inget ' + + 'basbelopp för omvänd skattskyldighet (ruta 20-24). Skatteverket ' + + 'kräver att båda sidor finns med (ML 13 kap; SKV felkod FK004). ' + + 'Kontrollera att leverantörsfakturor med omvänd skattskyldighet ' + + 'är bokförda med basbelopp på 44xx/45xx-konton.', + rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'], + }) + } + + // Mirror: basis present but no output VAT — equally broken, often a + // half-finished manual posting. + if (rcBasis > eps && rcOutput <= eps) { + findings.push({ + code: 'RC_OUTPUT_MISSING', + status: 'ERROR', + message: + 'Du har redovisat basbelopp för omvänd skattskyldighet (ruta 20-24) ' + + 'men ingen utgående moms (ruta 30-32). Vid omvänd skattskyldighet ' + + 'måste köparen redovisa både underlag och fiktiv moms (ML 13 kap). ' + + 'Kontrollera att fiktiv moms är bokförd på 2614/2624/2634.', + rutor: ['ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32'], + }) + } + + // The fiktiv-moms-pair must net to zero in the buyer's input deduction. + // We can't isolate the RC portion of ruta 48 without the breakdown, but + // we can flag when ruta 48 is smaller than rcOutput — that means the + // RC purchase didn't fully recover the calculated input VAT, which is + // a strong signal that one half of the 2645/2614 pair is missing. + if (rcOutput > eps && rutor.ruta48 + eps < rcOutput) { + findings.push({ + code: 'RC_INPUT_VAT_MISMATCH', + status: 'WARNING', + message: + 'Utgående moms på omvänd skattskyldighet (ruta 30-32) är högre än ' + + 'avdragsgill ingående moms (ruta 48). Vid full avdragsrätt ska ' + + 'beräknad ingående moms (2645/2647) nolla ut den fiktiva utgående ' + + 'momsen. Kontrollera att 2645/2647 är bokförd för varje 2614/2624/2634-rad.', + rutor: ['ruta30', 'ruta31', 'ruta32', 'ruta48'], + }) + } + + // SummaMoms drift — sanity check that our local ruta49 matches what the + // mapper will send. If this fires, the calculator and mapper disagree + // and we'd hit SKV's FK009. + const expectedRuta49 = + rutor.ruta10 + rutor.ruta11 + rutor.ruta12 + + rutor.ruta30 + rutor.ruta31 + rutor.ruta32 + + rutor.ruta60 + rutor.ruta61 + rutor.ruta62 - + rutor.ruta48 + if (Math.abs(expectedRuta49 - rutor.ruta49) > eps) { + findings.push({ + code: 'SUMMA_MOMS_DRIFT', + status: 'ERROR', + message: + 'Beräknad ruta 49 (moms att betala) stämmer inte överens med summan ' + + 'av övriga rutor. Detta tyder på avrundningsfel i bokföringen. ' + + 'Kontrollera huvudboken för perioden innan inlämning.', + rutor: ['ruta49'], + }) + } + + return findings +} diff --git a/lib/salary/__tests__/personnummer.test.ts b/lib/salary/__tests__/personnummer.test.ts index 40ccadc8..85744c1e 100644 --- a/lib/salary/__tests__/personnummer.test.ts +++ b/lib/salary/__tests__/personnummer.test.ts @@ -80,8 +80,12 @@ describe('calculateAgeAtYearStart', () => { }) describe('maskPersonnummer', () => { - it('masks with XXXXXXXX-XXXX format', () => { - expect(maskPersonnummer('9802')).toBe('XXXXXXXX-9802') + it('shows birthdate and masks the 4-digit suffix', () => { + expect(maskPersonnummer('199001019802')).toBe('19900101-XXXX') + }) + + it('strips non-digits before masking', () => { + expect(maskPersonnummer('19900101-9802')).toBe('19900101-XXXX') }) }) diff --git a/lib/salary/pdf/payslip-template.tsx b/lib/salary/pdf/payslip-template.tsx index e36e31ed..91c4e8a5 100644 --- a/lib/salary/pdf/payslip-template.tsx +++ b/lib/salary/pdf/payslip-template.tsx @@ -203,7 +203,7 @@ export interface PayslipData { // Employee employeeName: string - personnummerMasked: string // XXXXXXXX-XXXX + personnummerMasked: string // YYYYMMDD-XXXX employmentType: string // Period diff --git a/lib/salary/personnummer.ts b/lib/salary/personnummer.ts index 4fcd2a45..efc94ff9 100644 --- a/lib/salary/personnummer.ts +++ b/lib/salary/personnummer.ts @@ -151,10 +151,11 @@ export function calculateAgeAtYearStart(personnummer: string, year: number): num } /** - * Mask personnummer for display: XXXXXXXX-XXXX + * Mask personnummer for display: YYYYMMDD-XXXX (birthdate visible, suffix hidden). */ -export function maskPersonnummer(last4: string): string { - return `XXXXXXXX-${last4}` +export function maskPersonnummer(personnummer: string): string { + const digits = personnummer.replace(/\D/g, '') + return `${digits.slice(0, 8)}-XXXX` } /** diff --git a/lib/skatteverket/__tests__/bank-counterpart.test.ts b/lib/skatteverket/__tests__/bank-counterpart.test.ts new file mode 100644 index 00000000..766d0034 --- /dev/null +++ b/lib/skatteverket/__tests__/bank-counterpart.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest' +import { + findBankSkvCounterparts, + BANK_SKV_DATE_WINDOW_DAYS, +} from '../bank-counterpart' +import type { StoredSkattekontoTransaction } from '@/types/skatteverket' + +function skv( + partial: Partial & + Pick, +): Pick { + return partial +} + +describe('findBankSkvCounterparts', () => { + it('pairs a -5000 bank outflow with a +5000 SKV inflow within window', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 })], + }) + expect(result.get('bank-1')).toBe('2026-03-17') + }) + + it('pairs a +5000 bank inflow (refund) with a -5000 SKV outflow', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-20', amount: 5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-19', belopp_skatteverket: -5000 })], + }) + expect(result.get('bank-1')).toBe('2026-03-19') + }) + + it('does NOT pair when signs are equal (not a transfer)', () => { + // Bank -5000 (outgoing) and SKV -5000 (outgoing from skattekonto) + // would mean the user both paid 5000 from bank AND was charged 5000 + // by SKV. Not the same event — independent cash flows. + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: -5000 })], + }) + expect(result.has('bank-1')).toBe(false) + }) + + it('does NOT pair when amounts differ even slightly', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000.01 })], + }) + expect(result.has('bank-1')).toBe(false) + }) + + it('rounds to öre — 5000.001 equals 5000', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [ + skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000.001 }), + ], + }) + expect(result.get('bank-1')).toBe('2026-03-17') + }) + + it('does NOT pair when SKV date is outside the ±14 day window', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-20', belopp_skatteverket: 5000 })], + }) + expect(result.has('bank-1')).toBe(false) + }) + + it('pairs at the exact 14-day boundary', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-15', belopp_skatteverket: 5000 })], + }) + expect(result.get('bank-1')).toBe('2026-03-15') + }) + + it('first plausible match wins when multiple SKV rows would qualify', () => { + // Two SKV inflows of 5000 within window — the first one (in iteration + // order) wins. UI only shows one hint, so we don't need to rank. + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [ + skv({ id: 'skv-a', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 }), + skv({ id: 'skv-b', transaktionsdatum: '2026-03-15', belopp_skatteverket: 5000 }), + ], + }) + expect(result.get('bank-1')).toBe('2026-03-17') + }) + + it('handles multiple bank txs independently', () => { + const result = findBankSkvCounterparts({ + bankRows: [ + { id: 'bank-a', date: '2026-03-16', amount: -5000 }, + { id: 'bank-b', date: '2026-04-16', amount: -3000 }, + ], + skvRows: [ + skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 }), + skv({ id: 'skv-2', transaktionsdatum: '2026-04-18', belopp_skatteverket: 3000 }), + ], + }) + expect(result.size).toBe(2) + expect(result.get('bank-a')).toBe('2026-03-17') + expect(result.get('bank-b')).toBe('2026-04-18') + }) + + it('ignores zero-amount bank tx', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: 0 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 0 })], + }) + expect(result.has('bank-1')).toBe(false) + }) + + it('returns empty map when no SKV rows are provided', () => { + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-16', amount: -5000 }], + skvRows: [], + }) + expect(result.size).toBe(0) + }) + + it('returns empty map when no bank rows are provided', () => { + const result = findBankSkvCounterparts({ + bankRows: [], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-17', belopp_skatteverket: 5000 })], + }) + expect(result.size).toBe(0) + }) + + it('respects a custom dateWindowDays override', () => { + // 20 days apart — would fail default window, passes with override. + const result = findBankSkvCounterparts({ + bankRows: [{ id: 'bank-1', date: '2026-03-01', amount: -5000 }], + skvRows: [skv({ id: 'skv-1', transaktionsdatum: '2026-03-21', belopp_skatteverket: 5000 })], + dateWindowDays: 30, + }) + expect(result.get('bank-1')).toBe('2026-03-21') + }) + + it('exposes a sensible default window constant', () => { + expect(BANK_SKV_DATE_WINDOW_DAYS).toBe(14) + }) +}) diff --git a/lib/skatteverket/bank-counterpart.ts b/lib/skatteverket/bank-counterpart.ts new file mode 100644 index 00000000..071112a1 --- /dev/null +++ b/lib/skatteverket/bank-counterpart.ts @@ -0,0 +1,76 @@ +import type { StoredSkattekontoTransaction } from '@/types/skatteverket' + +/** + * Heuristic for spotting a 1930↔1630-transfer that has been observed + * from both sides (bank PSD2 + Skatteverket API). + * + * Used to render a passive dublett-varning on the bank-tx card in + * /transactions so the user doesn't book the same transfer twice (once + * via /transactions, once via /skattekonto). + * + * Rule, intentionally conservative: + * - Equal absolute amount (rounded to öre) + * - Opposite signs (a transfer looks like -X on bank, +X on SKV — or + * vice versa for a refund). Same-sign pairs are unrelated cash flows + * that happen to share an amount. + * - Transaktionsdatum within ±DATE_WINDOW_DAYS of bank.date. Real + * settlement is usually 1–3 working days but we widen the window to + * handle weekends and holidays. + * + * The function is non-blocking: false positives just mean an extra + * warning panel the user can ignore. False negatives mean no warning + * (user might double-book — but the SKV row will still have its own + * `match_suggestion` once they book one side, so the dublett-flow has a + * second chance to fire). + */ + +export const BANK_SKV_DATE_WINDOW_DAYS = 14 + +interface BankCounterpartInput { + /** Bank transactions that are still uncategorized (inbox candidates). */ + bankRows: ReadonlyArray<{ id: string; date: string; amount: number }> + /** SKV rows that have not been linked to a verifikat yet. */ + skvRows: ReadonlyArray< + Pick + > + /** Override for testing — defaults to BANK_SKV_DATE_WINDOW_DAYS. */ + dateWindowDays?: number +} + +function isoToTime(iso: string): number { + return new Date(iso + 'T00:00:00Z').getTime() +} + +function diffDays(a: string, b: string): number { + return Math.abs(isoToTime(a) - isoToTime(b)) / 86_400_000 +} + +/** + * Map each bank tx that has a plausible SKV counterpart to that SKV row's + * transaktionsdatum. First plausible match wins per bank tx — we don't + * return a ranked list since the UI only renders a single hint per card. + */ +export function findBankSkvCounterparts({ + bankRows, + skvRows, + dateWindowDays = BANK_SKV_DATE_WINDOW_DAYS, +}: BankCounterpartInput): Map { + const result = new Map() + if (skvRows.length === 0 || bankRows.length === 0) return result + + for (const tx of bankRows) { + const txAmount = Math.round(Math.abs(tx.amount) * 100) / 100 + if (txAmount === 0) continue + const txSign = Math.sign(tx.amount) + for (const r of skvRows) { + const skvAmount = Math.round(Math.abs(Number(r.belopp_skatteverket)) * 100) / 100 + if (skvAmount !== txAmount) continue + // Transfer scenario: bank-side and SKV-side have opposite signs. + if (txSign === Math.sign(Number(r.belopp_skatteverket))) continue + if (diffDays(r.transaktionsdatum, tx.date) > dateWindowDays) continue + result.set(tx.id, r.transaktionsdatum) + break + } + } + return result +} diff --git a/types/skatteverket.ts b/types/skatteverket.ts new file mode 100644 index 00000000..47154a3c --- /dev/null +++ b/types/skatteverket.ts @@ -0,0 +1,53 @@ +/** + * Skatteverket data shapes used by core UI (the /transactions page lives + * in core, but renders skattekonto rows alongside bank tx). The DB table + * `skattekonto_transactions` lives in core migrations even when the + * skatteverket extension is disabled — the extension only owns the API + * that populates it. Keeping these types in core means components can + * render the table's shape without depending on the extension module. + * + * If skatteverket is disabled, the API returns 503 and the UI just sees + * an empty list — the types remain valid descriptors of the schema. + */ + +/** Row shape for the `skattekonto_transactions` table (DB → app). */ +export interface StoredSkattekontoTransaction { + id: string + company_id: string + transaktionsidentitet: number | null + dedup_key: string + transaktionsdatum: string + forfallodatum: string | null + ranteberakningsdatum: string | null + transaktionstext: string + belopp_skatteverket: number + belopp_kronofogden: number | null + status: 'booked' | 'upcoming' + journal_entry_id: string | null + imported_at: string + updated_at: string +} + +/** + * Single best candidate verifikat for an unmatched SKV row. Attached by + * the `/skattekonto/transaktioner` endpoint when exactly one strong match + * exists, so the UI can offer a one-click "koppla till A12" hint instead + * of forcing the user to open the full Matcha-dialog. + */ +export interface SkattekontoMatchSuggestion { + journal_entry_id: string + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + status: 'draft' | 'posted' | 'reversed' +} + +/** + * API response variant: stored row plus optional auto-match suggestion. + * `match_suggestion` is optional because kommande/upcoming rows skip the + * enrichment step entirely (no journal entry can match a future event). + */ +export interface SkattekontoTransactionWithSuggestion extends StoredSkattekontoTransaction { + match_suggestion?: SkattekontoMatchSuggestion | null +}