Bug/momsdeklaration skv (#449)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
eb77ad50b5
commit
980f29dae8
@@ -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/<name>/`, 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/<name>/`, 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
|
||||
|
||||
|
||||
@@ -1393,6 +1393,7 @@ function VatDeclarationView() {
|
||||
year={year}
|
||||
period={period}
|
||||
hasData={data !== null}
|
||||
rutor={data?.rutor ?? null}
|
||||
/>
|
||||
|
||||
{!data && !loading && !error && (
|
||||
|
||||
@@ -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<SaldoEnvelope | null>(null)
|
||||
@@ -42,6 +63,12 @@ export default function SkattekontoPage() {
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [bookingId, setBookingId] = useState<string | null>(null)
|
||||
const [notConnected, setNotConnected] = useState(false)
|
||||
const [matchOpenFor, setMatchOpenFor] = useState<StoredSkattekontoTransaction | null>(
|
||||
null,
|
||||
)
|
||||
const [matchCandidates, setMatchCandidates] = useState<MatchCandidate[] | null>(null)
|
||||
const [matchLoading, setMatchLoading] = useState(false)
|
||||
const [matchSubmitting, setMatchSubmitting] = useState<string | null>(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() {
|
||||
<Tabs defaultValue="booked">
|
||||
<TabsList>
|
||||
<TabsTrigger value="booked">
|
||||
Bokförda {tx?.booked ? `(${tx.booked.length})` : ''}
|
||||
Genomförda {tx?.booked ? `(${tx.booked.length})` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
|
||||
@@ -192,14 +275,16 @@ export default function SkattekontoPage() {
|
||||
<TransactionTable
|
||||
rows={tx?.booked ?? []}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
emptyText="Inga bokförda transaktioner än."
|
||||
emptyText="Inga genomförda transaktioner än."
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="upcoming" className="mt-4">
|
||||
<TransactionTable
|
||||
rows={tx?.upcoming ?? []}
|
||||
onBokfor={bokfor}
|
||||
onMatch={openMatch}
|
||||
bookingId={bookingId}
|
||||
emptyText="Inga kommande transaktioner."
|
||||
showForfallodatum
|
||||
@@ -208,6 +293,18 @@ export default function SkattekontoPage() {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MatchDialog
|
||||
row={matchOpenFor}
|
||||
candidates={matchCandidates}
|
||||
loading={matchLoading}
|
||||
submittingId={matchSubmitting}
|
||||
onClose={() => {
|
||||
setMatchOpenFor(null)
|
||||
setMatchCandidates(null)
|
||||
}}
|
||||
onConfirm={confirmMatch}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<TableCell className="tabular-nums">{row.forfallodatum ?? '–'}</TableCell>
|
||||
)}
|
||||
<TableCell>{row.transaktionstext}</TableCell>
|
||||
<TableCell>
|
||||
{row.transaktionstext}
|
||||
{!isBooked && row.match_suggestion && (
|
||||
<p className="mt-1 text-xs text-warning">
|
||||
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})
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${negative ? 'text-destructive' : ''}`}
|
||||
>
|
||||
@@ -399,6 +509,10 @@ function TransactionTable({
|
||||
<FileCheck className="h-3 w-3" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
) : row.match_suggestion ? (
|
||||
<Badge variant="outline" className="border-warning text-warning">
|
||||
Möjlig dublett
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
)}
|
||||
@@ -411,14 +525,25 @@ function TransactionTable({
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
>
|
||||
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onMatch(row)}
|
||||
title="Koppla till befintligt verifikat"
|
||||
>
|
||||
<Link2 className="mr-1 h-3.5 w-3.5" />
|
||||
Matcha
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
>
|
||||
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -428,3 +553,113 @@ function TransactionTable({
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={o => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
|
||||
<DialogDescription>
|
||||
{row && (
|
||||
<>
|
||||
{row.transaktionsdatum} • {row.transaktionstext} •{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Söker kandidater…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length === 0 && (
|
||||
<div className="space-y-2 py-4 text-sm">
|
||||
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
|
||||
<p className="text-muted-foreground">
|
||||
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 <strong>Bokför</strong> för
|
||||
att skapa ett nytt verifikat istället.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length > 0 && (
|
||||
<div className="max-h-[420px] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Verifikat</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map(c => (
|
||||
<TableRow key={c.journal_entry_id}>
|
||||
<TableCell className="tabular-nums">{c.entry_date}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{c.voucher_series && c.voucher_number
|
||||
? `${c.voucher_series}${c.voucher_number}`
|
||||
: '–'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate">
|
||||
{c.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'posted' ? (
|
||||
<Badge variant="secondary">Bokförd</Badge>
|
||||
) : c.status === 'draft' ? (
|
||||
<Badge variant="outline">Utkast</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Makulerad</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(c.journal_entry_id)}
|
||||
disabled={submittingId === c.journal_entry_id}
|
||||
>
|
||||
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Avbryt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(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<SkattekontoTransactionWithSuggestion[]>([])
|
||||
const [skvProcessingId, setSkvProcessingId] = useState<string | null>(null)
|
||||
const [skvMatchTarget, setSkvMatchTarget] = useState<StoredSkattekontoTransaction | null>(
|
||||
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() {
|
||||
))}
|
||||
</div>
|
||||
) : mode === 'inbox' ? (
|
||||
uncategorizedTransactions.length === 0 ? (
|
||||
inboxItems.length === 0 ? (
|
||||
<InboxZeroState
|
||||
hasTransactions={transactions.length > 0}
|
||||
hasTransactions={transactions.length > 0 || skvRows.length > 0}
|
||||
onCreateTransaction={() => setIsDialogOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Källa:</span>
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 transition-colors',
|
||||
sourceFilter === 'all'
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Alla ({uncategorizedTransactions.length + skvUnmatched.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('bank')}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 transition-colors',
|
||||
sourceFilter === 'bank'
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bank ({uncategorizedTransactions.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('skatteverket')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-3 py-1 transition-colors',
|
||||
sourceFilter === 'skatteverket'
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Landmark className="h-3 w-3" />
|
||||
Skatteverket ({skvUnmatched.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{uncategorizedTransactions.map((transaction) => (
|
||||
<TransactionInboxCard
|
||||
key={transaction.id}
|
||||
transaction={transaction}
|
||||
suggestions={categorySuggestions[transaction.id]}
|
||||
templateSuggestions={templateSuggestions[transaction.id]}
|
||||
processingId={processingId}
|
||||
isBatchMode={isBatchMode}
|
||||
isSelected={selectedIds.has(transaction.id)}
|
||||
entityType={entityType}
|
||||
onCategorize={handleCategorize}
|
||||
onMarkPrivate={handleMarkPrivate}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onOpenQuickReview={handleOpenQuickReview}
|
||||
onOpenTemplateReview={handleOpenTemplateReview}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
/>
|
||||
))}
|
||||
{inboxItems.map(item =>
|
||||
item.source === 'bank' ? (
|
||||
<TransactionInboxCard
|
||||
key={`bank-${item.data.id}`}
|
||||
transaction={item.data}
|
||||
suggestions={categorySuggestions[item.data.id]}
|
||||
templateSuggestions={templateSuggestions[item.data.id]}
|
||||
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
|
||||
processingId={processingId}
|
||||
isBatchMode={isBatchMode}
|
||||
isSelected={selectedIds.has(item.data.id)}
|
||||
entityType={entityType}
|
||||
onCategorize={handleCategorize}
|
||||
onMarkPrivate={handleMarkPrivate}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onOpenQuickReview={handleOpenQuickReview}
|
||||
onOpenTemplateReview={handleOpenTemplateReview}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
/>
|
||||
) : (
|
||||
<SkattekontoInboxCard
|
||||
key={`skv-${item.data.id}`}
|
||||
row={item.data}
|
||||
matchSuggestion={item.data.match_suggestion}
|
||||
processing={skvProcessingId === item.data.id}
|
||||
onBokfor={handleSkvBokfor}
|
||||
onMatch={r => setSkvMatchTarget(r)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<TransactionHistoryList
|
||||
transactions={transactions}
|
||||
skvRows={skvRows}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onSkvBokfor={handleSkvBokfor}
|
||||
onSkvMatch={r => setSkvMatchTarget(r)}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
@@ -1150,6 +1333,13 @@ export default function TransactionsPage() {
|
||||
</Dialog>
|
||||
|
||||
<DestructiveConfirmDialog {...deleteDialogProps} />
|
||||
|
||||
<SkattekontoMatchDialog
|
||||
row={skvMatchTarget}
|
||||
open={!!skvMatchTarget}
|
||||
onClose={() => setSkvMatchTarget(null)}
|
||||
onMatched={handleSkvMatched}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -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 <SkatteverketPanelInner {...props} />
|
||||
}
|
||||
|
||||
function SkatteverketPanelInner({ periodType, year, period, hasData }: SkatteverketPanelProps) {
|
||||
function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) {
|
||||
const [status, setStatus] = useState<SkatteverketStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Lokala kontroller
|
||||
</p>
|
||||
{localChecks.map((c, i) => (
|
||||
<div
|
||||
key={`${c.code}-${i}`}
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-2.5 ${
|
||||
c.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{c.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{c.code}</span>
|
||||
{c.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results from Skatteverket */}
|
||||
{kontroller.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Valideringsresultat
|
||||
Skatteverkets valideringsresultat
|
||||
</p>
|
||||
{kontroller.map((k, i) => (
|
||||
<div
|
||||
@@ -513,8 +583,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleValidate}
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={localBlocked ? 'Åtgärda lokala kontrollfel innan validering' : undefined}
|
||||
>
|
||||
{actionLoading === 'validate' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -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' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -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<MatchCandidate[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={o => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
|
||||
<DialogDescription>
|
||||
{row && (
|
||||
<>
|
||||
{row.transaktionsdatum} • {row.transaktionstext} •{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Söker kandidater…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length === 0 && (
|
||||
<div className="space-y-2 py-4 text-sm">
|
||||
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
|
||||
<p className="text-muted-foreground">
|
||||
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 <strong>Bokför</strong> för
|
||||
att skapa ett nytt verifikat istället.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && candidates && candidates.length > 0 && (
|
||||
<div className="max-h-[420px] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Verifikat</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{candidates.map(c => (
|
||||
<TableRow key={c.journal_entry_id}>
|
||||
<TableCell className="tabular-nums">{c.entry_date}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{c.voucher_series && c.voucher_number
|
||||
? `${c.voucher_series}${c.voucher_number}`
|
||||
: '–'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate">
|
||||
{c.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'posted' ? (
|
||||
<Badge variant="secondary">Bokförd</Badge>
|
||||
) : c.status === 'draft' ? (
|
||||
<Badge variant="outline">Utkast</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Makulerad</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => confirmMatch(c.journal_entry_id)}
|
||||
disabled={submittingId === c.journal_entry_id}
|
||||
>
|
||||
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Avbryt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95, x: -16 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
onAnimationComplete={definition => {
|
||||
if (
|
||||
typeof definition === 'object' &&
|
||||
'opacity' in definition &&
|
||||
definition.opacity === 0
|
||||
) {
|
||||
onAnimationComplete?.(row.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
matchSuggestion ? 'border-warning' : 'border-warning/50',
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium truncate">{row.transaktionstext}</p>
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<Landmark className="h-3 w-3" />
|
||||
Skatteverket
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(row.transaktionsdatum)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchSuggestion && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
Möjlig dublett av verifikat{' '}
|
||||
{matchSuggestion.voucher_series && matchSuggestion.voucher_number
|
||||
? `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}`
|
||||
: '(utkast)'}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate">
|
||||
{matchSuggestion.entry_date} • {matchSuggestion.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Det här ser ut som samma kassaflöde — koppla istället för
|
||||
att bokföra om.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t">
|
||||
{matchSuggestion ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Link2 className="mr-1.5 h-3 w-3" />
|
||||
Koppla till verifikat
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
Bokför ändå
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
Bokför
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Link2 className="mr-1.5 h-3 w-3" />
|
||||
Matcha mot verifikat
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -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<HistoryFilter>('all')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('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 (
|
||||
<div className="space-y-4">
|
||||
{/* Search + filter pills */}
|
||||
@@ -68,6 +127,27 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSourceFilter && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Källa:</span>
|
||||
{(['all', 'bank', 'skatteverket'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSourceFilter(s)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-3 py-1 transition-colors',
|
||||
sourceFilter === s
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'skatteverket' && <Landmark className="h-3 w-3" />}
|
||||
{s === 'all' ? 'Alla' : s === 'bank' ? 'Bank' : 'Skatteverket'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card>
|
||||
@@ -83,124 +163,24 @@ export default function TransactionHistoryList({
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((transaction) => (
|
||||
<Card
|
||||
key={transaction.id}
|
||||
data-tx-id={transaction.id}
|
||||
className="hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
transaction.amount > 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null &&
|
||||
!(
|
||||
transaction.is_business &&
|
||||
transaction.category === 'uncategorized' &&
|
||||
transaction.journal_entry_id
|
||||
) && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge
|
||||
variant={transaction.is_business ? 'default' : 'secondary'}
|
||||
>
|
||||
{transaction.is_business
|
||||
? getCategoryDisplayName(transaction.category)
|
||||
: 'Privat'}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-primary border-primary">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Kopplad till faktura
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Ej bokförd
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!transaction.journal_entry_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filtered.map((item) =>
|
||||
item.source === 'bank' ? (
|
||||
<BankHistoryRow
|
||||
key={`bank-${item.data.id}`}
|
||||
transaction={item.data}
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
<SkattekontoHistoryRow
|
||||
key={`skv-${item.data.id}`}
|
||||
row={item.data}
|
||||
onBokfor={onSkvBokfor}
|
||||
onMatch={onSkvMatch}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{hasMore && onLoadMore && !searchTerm && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
@@ -224,3 +204,246 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BankHistoryRow({
|
||||
transaction,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Card data-tx-id={transaction.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
transaction.amount > 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null &&
|
||||
!(
|
||||
transaction.is_business &&
|
||||
transaction.category === 'uncategorized' &&
|
||||
transaction.journal_entry_id
|
||||
) && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant={transaction.is_business ? 'default' : 'secondary'}>
|
||||
{transaction.is_business
|
||||
? getCategoryDisplayName(transaction.category)
|
||||
: 'Privat'}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-primary border-primary">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Kopplad till faktura
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Ej bokförd
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!transaction.journal_entry_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onOpenCategoryDialog(transaction)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
{!transaction.journal_entry_id && onDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(transaction.id)}
|
||||
aria-label="Ta bort transaktion"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="font-medium tabular-nums">
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
isIncome ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.transaktionstext}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(row.transaktionsdatum)}</span>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Landmark className="h-3 w-3" />
|
||||
Skatteverket
|
||||
</Badge>
|
||||
{isBooked ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
) : row.match_suggestion ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="border-warning text-warning">
|
||||
Möjlig dublett
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isBooked && onMatch && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={row.match_suggestion ? 'default' : 'outline'}
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onMatch(row)}
|
||||
>
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{row.match_suggestion ? 'Koppla' : 'Matcha'}
|
||||
</Button>
|
||||
)}
|
||||
{!isBooked && !row.match_suggestion && onBokfor && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-10 text-xs"
|
||||
onClick={() => onBokfor(row)}
|
||||
>
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
{isBooked && (
|
||||
<Button asChild size="sm" variant="ghost" className="h-10 text-xs">
|
||||
<Link href={`/bookkeeping/${row.journal_entry_id}`}>Visa verifikat</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
isIncome && 'text-success',
|
||||
)}
|
||||
>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
{skvCounterpartDate && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
|
||||
<p className="min-w-0">
|
||||
<span className="font-medium">Möjlig 1930↔1630-överföring.</span>{' '}
|
||||
Det finns en skattekonto-händelse den{' '}
|
||||
<span className="tabular-nums">{skvCounterpartDate}</span> 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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: checkbox + icon + info */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
|
||||
@@ -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<string, unknown>), personnummer: `XXXXXXXX-${(e.employee as Record<string, unknown>).personnummer_last4}` } : null })) }
|
||||
return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record<string, unknown>), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record<string, unknown>).personnummer as string)) } : null })) }
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Map<string, SkattekontoMatchCandidate>> {
|
||||
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<string, SkattekontoMatchCandidate>()
|
||||
|
||||
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<string>()
|
||||
|
||||
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<StoredSkattekontoTransaction>()
|
||||
|
||||
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<string>()
|
||||
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<void> {
|
||||
const { data: tx, error: txError } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single<StoredSkattekontoTransaction>()
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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<number, number> {
|
||||
const baseByRate = new Map<number, number>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<keyof VatDeclarationRutor>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface PayslipData {
|
||||
|
||||
// Employee
|
||||
employeeName: string
|
||||
personnummerMasked: string // XXXXXXXX-XXXX
|
||||
personnummerMasked: string // YYYYMMDD-XXXX
|
||||
employmentType: string
|
||||
|
||||
// Period
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<StoredSkattekontoTransaction> &
|
||||
Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'>,
|
||||
): Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'>
|
||||
>
|
||||
/** 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<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user