diff --git a/CLAUDE.md b/CLAUDE.md index 99ff8b00..76def15f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ gnubok is a Swedish-focused accounting SaaS for sole traders (enskild firma) and **Tech stack**: Next.js 16.1.5 (App Router), React 19.2.3, TypeScript 5 (strict), Zod 4, Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting, Docker (self-hosted). -**Integrations**: Enable Banking (PSD2), TIC Identity (company lookup), Anthropic SDK, OpenAI (embeddings), Resend (email), Sentry (error tracking), Svix (webhooks), web-push (notifications), JSZip (archive export), sharp (image processing), Framer Motion (animations). +**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). **Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English. **License**: AGPL-3.0-or-later. @@ -30,11 +30,11 @@ npm run setup:extensions # Regenerate extension registry from extensions.config. - **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. 30+ event types defined in `lib/events/types.ts`. The event log handler persists actionable events to `event_log` table for external automation. +- **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`. -- **Core reports** (in `lib/reports/`, not extensions): balance sheet, income statement, trial balance, general ledger, AR/supplier ledger, AR/supplier reconciliation, bank reconciliation status, VAT declaration, journal register, monthly breakdown, continuity check, opening balances, KPI, NE-bilaga, INK2 declaration, SIE export, full archive export. -- **Types**: All shared types in `types/index.ts` (~2,200 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`. +- **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). --- @@ -167,13 +167,13 @@ Extensions are opt-in plugins in `extensions/general//`, controlled by `ex | `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 | -| `receipt-ocr` | AI receipt scanning and extraction | No | -| `ai-categorization` | AI transaction categorization | No | -| `ai-chat` | AI assistant for bookkeeping questions | No | -| `push-notifications` | Web push notifications for events | No | +| `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 @@ -195,7 +195,7 @@ Extensions are opt-in plugins in `extensions/general//`, controlled by `ex 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. -**MCP extension** (`extensions/general/mcp-server/`): 26 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. 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 — 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`. **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). @@ -246,20 +246,24 @@ export async function POST(request: Request) { | Directory | Purpose | |-----------|---------| -| `bookkeeping/` | Engine, entry generators, mapping, templates, BAS data | +| `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, 30+ event types, event log handler | -| `auth/` | API keys, require-auth, MFA, OAuth codes, invite tokens, cron auth | +| `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/` | 17 report generators (financial statements, ledgers, tax, exports) | +| `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, account mapper | +| `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 | +| `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 | @@ -269,9 +273,9 @@ export async function POST(request: Request) { | `bankgiro/` | Luhn checksum validation | | `calendar/` | ICS generator, calendar utilities | | `errors/` | Swedish error message mapping (Zod → Postgres → HTTP → fallback) | -| `hooks/` | React hooks (e.g., `use-unsaved-changes`) | -| `settings/` | Settings utilities | +| `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()` | --- @@ -296,14 +300,16 @@ export async function POST(request: Request) { | `/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, account, API settings | +| `/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 | @@ -316,21 +322,28 @@ export async function POST(request: Request) { - `/api/transactions/*` — Categorize, uncategorize, describe, book, match-invoice, match-supplier-invoice, batch operations, AI suggestions - `/api/customers/*`, `/api/suppliers/*` — CRUD - `/api/documents/*` — CRUD, versions, link, verify, match-sweep, verify cron -- `/api/reports/*` — 16 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) +- `/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 -- `/api/company/members/*` — List, CRUD, invite +- `/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 +- `/api/extensions/ext/[...path]` — Dynamic extension API routes (plus top-level `/api/extensions/enable-banking/*`, `/api/extensions/cloud-backup/*`, `/api/extensions/push-notifications/*`) --- @@ -346,9 +359,9 @@ export async function POST(request: Request) { ## Database & Migrations -**Location**: `supabase/migrations/` — 93 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. +**Location**: `supabase/migrations/` — 118 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. -### Key Tables (~47) +### Key Tables (~60) **Multi-tenant**: `companies`, `company_members`, `company_invitations`, `teams`, `team_members`, `team_invitations`, `user_preferences`, `profiles` @@ -362,17 +375,23 @@ export async function POST(request: Request) { **Documents**: `document_attachments` (WORM), `receipts`, `receipt_line_items` -**Settings & Config**: `company_settings`, `mapping_rules`, `categorization_templates`, `extension_data` +**Settings & Config**: `company_settings`, `mapping_rules`, `categorization_templates`, `booking_template_library`, `extension_data` **Dimensions**: `cost_centers`, `projects` -**Tax & Deadlines**: `tax_rates`, `deadlines`, `calendar_feeds`, `skatteverket_tokens` +**Tax & Deadlines**: `tax_rates`, `tax_table_rates`, `deadlines`, `calendar_feeds`, `skatteverket_tokens` -**API & Auth**: `api_keys` (with scopes), `oauth_used_codes` +**API & Auth**: `api_keys` (with scopes), `oauth_used_codes`, `bankid_identities` -**Audit & Ops**: `audit_log` (immutable), `event_log` (30-day TTL), `pending_operations`, `ai_usage_tracking` +**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` -**Other**: `salary_payments`, `sandbox_users` +**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` ### Key RPC Functions @@ -414,11 +433,14 @@ export async function POST(request: Request) { ## Skills, Git & CI -**Skills**: Always use `/frontend-design` for new UI. Use `vercel:deploy` for deployment. Use `/supabase-migration` for new migrations. Use `/erp-api-route` for new API routes. Use `/create-extension` for new extensions. Use `/swedish-bookkeeping` for accounting domain questions. +**Skills**: Always use `/frontend-design` for new UI. Use `vercel:deploy` for deployment. Use `/supabase-migration` for new migrations. Use `/erp-api-route` for new API routes. Use `/create-extension` for new extensions. Use the Swedish domain skills (`swedish-sie-import-export`, `swedish-accounting-compliance`, `swedish-vat`, `swedish-invoice-compliance`, `swedish-payroll`, `swedish-year-end-closing`, `swedish-financial-reporting`, `swedish-sru-filing`, `swedish-asset-accounting`, `swedish-project-accounting`, `swedish-tax-planning`) for accounting domain questions. **Git**: Conventional commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`). Atomic commits, branch from `main`. -**CI** (`.github/workflows/core-build.yml`): Resets extensions to empty, runs build + test, verifies no core code imports from `@/extensions/` directly. +**CI**: +- `.github/workflows/core-build.yml` — resets extensions to empty, runs build + test, verifies no core code imports from `@/extensions/` directly. +- `.github/workflows/swedish-compliance-review.yml` — Swedish accounting compliance review on PRs touching bookkeeping/reports/tax logic. +- `.github/workflows/docker-publish.yml` — pushes images to GHCR on main. **Docker** (`.github/workflows/docker-publish.yml`): Pushes to GHCR (`erp-mafia/erp-base`) on main push. 4-stage Dockerfile (base → deps → builder → runner) with Node 22 Alpine. Runtime env placeholder replacement via `docker-entrypoint.sh`. Docker Compose with app + supercronic cron service. @@ -436,9 +458,10 @@ Cron jobs defined in `vercel.json`: | `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 * * 0` | `/api/documents/verify/cron` | Document integrity verification | +| `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 | ### Docker (Self-Hosted) diff --git a/app/api/bookkeeping/accounts/activate/route.ts b/app/api/bookkeeping/accounts/activate/route.ts index a55dd1ed..3b1d9aa0 100644 --- a/app/api/bookkeeping/accounts/activate/route.ts +++ b/app/api/bookkeeping/accounts/activate/route.ts @@ -7,9 +7,11 @@ import { requireWritePermission } from '@/lib/auth/require-write' /** * POST /api/bookkeeping/accounts/activate * - * Batch-activate BAS accounts for a user. - * Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data. - * Skips any accounts that already exist for the user. + * Batch-activate BAS accounts for a user. Accepts { account_numbers: string[] }. + * - Inserts rows from BAS reference for accounts not yet in the chart. + * - Reactivates (is_active=true) accounts that already exist but are inactive. + * - Skips anything already active. + * - Returns { activated, reactivated, skipped, unknown } so callers can react. */ export async function POST(request: Request) { const supabase = await createClient() @@ -31,62 +33,97 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 }) } - // Check which accounts already exist - const { data: existing } = await supabase + const uniqueNumbers = [...new Set(accountNumbers)] + + // Fetch existing rows with current is_active state + const { data: existing, error: fetchError } = await supabase .from('chart_of_accounts') - .select('account_number') + .select('account_number, is_active') .eq('company_id', companyId) - .in('account_number', accountNumbers) + .in('account_number', uniqueNumbers) - const existingNumbers = new Set((existing || []).map((a) => a.account_number)) - - // Build rows for accounts that don't already exist - const newAccounts = accountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) - if (!ref) return null - - return { - user_id: user.id, - company_id: companyId, - account_number: ref.account_number, - account_name: ref.account_name, - account_class: ref.account_class, - account_group: ref.account_group, - account_type: ref.account_type, - normal_balance: ref.normal_balance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: ref.description, - sru_code: ref.sru_code, - sort_order: parseInt(ref.account_number), - } - }) - .filter(Boolean) - - if (newAccounts.length === 0) { - return NextResponse.json({ - data: [], - message: 'All accounts already activated', - activated: 0, - skipped: accountNumbers.length, - }) + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) } - const { data, error } = await supabase - .from('chart_of_accounts') - .insert(newAccounts) - .select() + const existingByNumber = new Map( + (existing || []).map((a) => [a.account_number, a.is_active]) + ) - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + const toReactivate: string[] = [] + const toInsert: Array> = [] + const unknown: string[] = [] + let skipped = 0 + + for (const num of uniqueNumbers) { + if (existingByNumber.has(num)) { + if (existingByNumber.get(num) === true) { + skipped += 1 + } else { + toReactivate.push(num) + } + continue + } + const row = buildInsertRow(num, user.id, companyId) + if (row) { + toInsert.push(row) + } else { + unknown.push(num) + } + } + + let reactivatedRows: { account_number: string }[] = [] + if (toReactivate.length > 0) { + const { data, error } = await supabase + .from('chart_of_accounts') + .update({ is_active: true }) + .eq('company_id', companyId) + .in('account_number', toReactivate) + .select('account_number') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + reactivatedRows = data || [] + } + + let insertedRows: { account_number: string }[] = [] + if (toInsert.length > 0) { + const { data, error } = await supabase + .from('chart_of_accounts') + .insert(toInsert) + .select('account_number') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + insertedRows = data || [] } return NextResponse.json({ - data, - activated: data?.length || 0, - skipped: accountNumbers.length - (data?.length || 0), + data: [...insertedRows, ...reactivatedRows], + activated: insertedRows.length, + reactivated: reactivatedRows.length, + skipped, + unknown, }) } + +function buildInsertRow(accountNumber: string, userId: string, companyId: string) { + const ref = getBASReference(accountNumber) + if (!ref) return null + return { + user_id: userId, + company_id: companyId, + account_number: ref.account_number, + account_name: ref.account_name, + account_class: ref.account_class, + account_group: ref.account_group, + account_type: ref.account_type, + normal_balance: ref.normal_balance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: ref.description, + sru_code: ref.sru_code, + sort_order: parseInt(ref.account_number), + } +} diff --git a/app/api/bookkeeping/accounts/bas-lookup/route.ts b/app/api/bookkeeping/accounts/bas-lookup/route.ts new file mode 100644 index 00000000..18dfe998 --- /dev/null +++ b/app/api/bookkeeping/accounts/bas-lookup/route.ts @@ -0,0 +1,42 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' + +/** + * GET /api/bookkeeping/accounts/bas-lookup?numbers=5010,2641 + * + * Returns BAS reference metadata (name, class, type) for a list of account + * numbers. Used by ActivateAccountsDialog to render human-readable labels + * before the user confirms activation. Unknown numbers are returned with + * account_name=null so the UI can flag them as non-BAS. + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const raw = searchParams.get('numbers') || '' + const numbers = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))] + if (numbers.length === 0) { + return NextResponse.json({ data: [] }) + } + + const data = numbers.map((num) => { + const ref = getBASReference(num) + if (!ref) { + return { account_number: num, account_name: null, known: false } + } + return { + account_number: ref.account_number, + account_name: ref.account_name, + account_class: ref.account_class, + account_type: ref.account_type, + known: true, + } + }) + + return NextResponse.json({ data }) +} diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 6c8420fb..1af2cd70 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CorrectJournalEntrySchema } from '@/lib/api/schemas' @@ -34,6 +35,9 @@ export async function POST( const result = await correctEntry(supabase, companyId, user.id, id, body.lines) return NextResponse.json({ data: result }) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to correct entry' }, { status: 400 } diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index c6b40c25..9502ee99 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' @@ -28,6 +29,9 @@ export async function POST( const reversalEntry = await reverseEntry(supabase, companyId, user.id, id) return NextResponse.json({ data: reversalEntry }) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to reverse entry' }, { status: 400 } diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 0ef844ac..e7337ffb 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' @@ -123,6 +124,9 @@ export async function POST(request: Request) { const entry = await createJournalEntry(supabase, companyId, user.id, body) return NextResponse.json({ data: entry }) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, { status: 400 } diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts index 5a79aff7..3519fc48 100644 --- a/app/api/import/opening-balance/execute/route.ts +++ b/app/api/import/opening-balance/execute/route.ts @@ -6,6 +6,7 @@ import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas' import { requireWritePermission } from '@/lib/auth/require-write' import { requireCompanyId } from '@/lib/company/context' import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { CreateJournalEntryLineInput } from '@/types' @@ -241,6 +242,9 @@ export async function POST(request: Request) { }, }) } catch (error) { + if (error instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(error) + } console.error('Opening balance execute error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Importen misslyckades' }, diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 2d249b72..894309a1 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -5,6 +5,7 @@ import { createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' @@ -160,6 +161,9 @@ export async function POST( journalEntryId = journalEntry?.id ?? null } } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create payment journal entry:', err) return NextResponse.json( { error: 'Kunde inte bokföra betalningen' }, diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index cea1df29..e335452a 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -16,6 +16,7 @@ import { createInvoiceJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, @@ -26,6 +27,7 @@ import { uploadDocument } from '@/lib/core/documents/document-service' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { createLogger } from '@/lib/logger' +import { appendProcessingHistory } from '@/lib/processing-history/append' import type { Transaction, TransactionCategory, @@ -43,6 +45,51 @@ const log = createLogger('pending-operations/commit') ensureInitialized() +/** + * Record a best-effort processing_history breadcrumb when the invoice's + * accrual journal entry couldn't be booked. The pending-operation itself + * still succeeds (invoice email delivered / status set), but the JE is + * missing — which in the accrual case means revenue (3001) and utgående + * moms (2611) are unposted for the period, understating the momsdeklaration. + * + * The event makes the gap visible and actionable: an operator or bokföring + * consultant can query processing_history for `InvoiceJournalEntrySkipped` + * and re-book the missing verifikation (via the activation dialog or + * manually) before the momsdeklaration is filed. Swallows its own errors + * to preserve the non-blocking contract with the caller. + */ +async function recordSkippedInvoiceJournalEntry( + invoiceId: string, + companyId: string, + userId: string, + operation: 'send_invoice' | 'mark_invoice_sent', + err: unknown +): Promise { + try { + const reasonCode = err instanceof AccountsNotInChartError + ? 'accounts_not_in_chart' + : 'journal_entry_error' + const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined + await appendProcessingHistory({ + companyId, + correlationId: invoiceId, + aggregateType: 'System', + aggregateId: invoiceId, + eventType: 'InvoiceJournalEntrySkipped', + payload: { + invoice_id: invoiceId, + operation, + reason_code: reasonCode, + ...(accountNumbers ? { account_numbers: accountNumbers } : {}), + }, + actor: { type: 'user', id: userId }, + occurredAt: new Date(), + }) + } catch (historyErr) { + log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr) + } +} + /** * Ensure a fiscal period exists for the given date, create one if needed. * Same logic as app/api/transactions/[id]/categorize/route.ts @@ -173,6 +220,7 @@ async function commitCategorizeTransaction( journalEntryId = journalEntry.id } } catch (err) { + if (err instanceof AccountsNotInChartError) throw err log.error('Failed to create journal entry:', err) return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 } } @@ -579,7 +627,18 @@ async function commitSendInvoice( createdJournalEntryId = je.id await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId) } - } catch { /* non-blocking */ } + } catch (err) { + // Non-blocking: the invoice is already sent by this point and the user + // can retry the journal entry separately. Re-throwing here would mean + // the email has already gone out but the outer 400 would report + // failure — worst of both worlds. + // + // Record a processing_history breadcrumb so the missing verifikation + // surfaces in audit trails and the momsdeklaration gap is actionable + // rather than silent. TODO: wire ActivateAccountsDialog into this + // pending-op flow, then re-introduce blocking for ACCOUNTS_NOT_IN_CHART. + await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err) + } } if (isRealInvoice) { @@ -647,7 +706,13 @@ async function commitMarkInvoiceSent( journalEntryId = je.id await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId) } - } catch { /* non-blocking */ } + } catch (err) { + // Non-blocking: invoice is already marked as sent. Record a + // processing_history breadcrumb so the missing JE is visible in audit + // trails. TODO: wire ActivateAccountsDialog into this pending-op flow, + // then re-introduce blocking for ACCOUNTS_NOT_IN_CHART here. + await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err) + } } return { data: { status: 'sent', journal_entry_id: journalEntryId } } @@ -722,6 +787,7 @@ async function commitMatchTransactionInvoice( journalEntryId = je?.id ?? null } } catch (err) { + if (err instanceof AccountsNotInChartError) throw err log.error('Failed to create match journal entry:', err) } @@ -822,30 +888,37 @@ export async function POST( // Execute based on operation type let result: { data?: Record; error?: string; status?: number } - switch (pendingOp.operation_type) { - case 'categorize_transaction': - result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params) - break - case 'create_customer': - result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params) - break - case 'create_invoice': - result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params) - break - case 'mark_invoice_paid': - result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params) - break - case 'send_invoice': - result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email) - break - case 'mark_invoice_sent': - result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params) - break - case 'match_transaction_invoice': - result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params) - break - default: - return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 }) + try { + switch (pendingOp.operation_type) { + case 'categorize_transaction': + result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params) + break + case 'create_customer': + result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params) + break + case 'create_invoice': + result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params) + break + case 'mark_invoice_paid': + result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params) + break + case 'send_invoice': + result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email) + break + case 'mark_invoice_sent': + result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params) + break + case 'match_transaction_invoice': + result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params) + break + default: + return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 }) + } + } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } + throw err } if (result.error) { diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index c7803b1c..82d50e81 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' ensureInitialized() @@ -61,6 +62,9 @@ export async function POST( try { await reverseEntry(supabase, companyId, user.id, entryId) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } // Entry may already be reversed — continue const msg = err instanceof Error ? err.message : '' if (!msg.includes('already reversed')) { diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 035cf0e4..44388edd 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types' @@ -131,7 +132,20 @@ export async function POST( .eq('id', creditNote.id) } } catch (err) { + // Roll back the just-inserted credit note (items cascade-delete) on + // any JE failure. A creditfaktura row without a corresponding reversal + // JE would leave ingående moms overstated for the period — same + // momsdeklaration-integrity concern as the POST-route rollback. + await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId) + + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create credit note journal entry:', err) + return NextResponse.json( + { error: 'Kunde inte bokföra kreditfakturan — försök igen eller ändra datum om perioden är låst.' }, + { status: 500 } + ) } } diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 40f7b2ce..bd2e3a53 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -6,6 +6,7 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' @@ -98,6 +99,9 @@ export async function POST( if (journalEntry) journalEntryId = journalEntry.id } } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create payment journal entry:', err) return NextResponse.json( { error: 'Kunde inte bokföra betalningen' }, diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index c3059f55..c4e1605a 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' @@ -194,7 +195,21 @@ export async function POST(request: Request) { .eq('id', invoice.id) } } catch (err) { + // Roll back the just-inserted supplier invoice (+ items via ON DELETE + // CASCADE) on any JE failure so we never leave a supplier_invoices row + // that has no registration JE. Under accrual method an orphan row + // means leverantörsskuld (2440) and ingående moms (2641) go unposted, + // which silently understates the momsdeklaration for the period. + await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) + + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create registration journal entry:', err) + return NextResponse.json( + { error: 'Kunde inte bokföra leverantörsfakturan — försök igen eller ändra datum om perioden är låst.' }, + { status: 500 } + ) } } diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 8b4fe239..a76da58f 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' @@ -65,6 +66,9 @@ export async function POST( lines, }) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, { status: 400 } diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index a480bfb8..64815732 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -5,6 +5,7 @@ import { createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' @@ -121,6 +122,9 @@ export async function POST( newState: { journal_entry_id: null }, }) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to storno conflicting journal entry:', err) return NextResponse.json( { error: 'Failed to reverse conflicting journal entry' }, @@ -200,6 +204,9 @@ export async function POST( journalEntryId = journalEntry?.id ?? null } } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create payment journal entry:', err) journalEntryError = err instanceof Error ? err.message : 'Unknown error' // Continue - we still want to update the invoice and transaction diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 26c57d2a..b51aae27 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -4,6 +4,7 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' @@ -127,6 +128,9 @@ export async function POST( if (journalEntry) journalEntryId = journalEntry.id } } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } console.error('Failed to create payment journal entry:', err) } diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts index cb5f9063..14f19063 100644 --- a/app/api/transactions/[id]/uncategorize/route.ts +++ b/app/api/transactions/[id]/uncategorize/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' @@ -60,6 +61,9 @@ export async function POST( try { await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) } catch (err) { + if (err instanceof AccountsNotInChartError) { + return accountsNotInChartResponse(err) + } const message = err instanceof Error ? err.message : 'Reversal failed' return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index 8160b701..296d54d5 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -142,8 +142,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value setSearch(newValue) - // Only emit valid account numbers to parent - if (/^\d{4}$/.test(newValue) && accounts.some(a => a.account_number === newValue)) { + // Emit any 4-digit numeric value to the parent. Unknown BAS numbers are + // accepted optimistically — the submit-time ActivateAccountsDialog lets + // the user activate missing accounts without leaving the form. + if (/^\d{4}$/.test(newValue)) { onChange(newValue) } if (!isOpen) { @@ -156,9 +158,12 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo } const handleBlur = () => { - // Small delay to allow dropdown click to fire first + // Small delay to allow dropdown click to fire first. Keep any 4-digit + // numeric value even if it's not in the currently-active chart — the + // submit handler will prompt to activate it. setTimeout(() => { - if (!accounts.some(a => a.account_number === search)) { + const isFourDigit = /^\d{4}$/.test(search) + if (!isFourDigit && !accounts.some(a => a.account_number === search)) { setSearch(value) } }, 150) @@ -223,9 +228,15 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo

Hittade inget konto som matchar.

-

- Kontot kan behöva aktiveras i din kontoplan. -

+ {/^\d{4}$/.test(search.trim()) ? ( +

+ Om det är ett giltigt BAS-konto aktiveras det när du bokför. +

+ ) : ( +

+ Kontot kan behöva aktiveras i din kontoplan. +

+ )} )} diff --git a/components/bookkeeping/ActivateAccountsDialog.tsx b/components/bookkeeping/ActivateAccountsDialog.tsx new file mode 100644 index 00000000..b9e3a71b --- /dev/null +++ b/components/bookkeeping/ActivateAccountsDialog.tsx @@ -0,0 +1,138 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Loader2, Plus } from 'lucide-react' + +export interface ActivateAccountsDialogProps { + open: boolean + accountNumbers: string[] + onConfirm: () => Promise | void + onCancel: () => void +} + +interface BasLookupRow { + account_number: string + account_name: string | null + known: boolean +} + +export function ActivateAccountsDialog({ + open, + accountNumbers, + onConfirm, + onCancel, +}: ActivateAccountsDialogProps) { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!open || accountNumbers.length === 0) return + let cancelled = false + setLoading(true) + fetch(`/api/bookkeeping/accounts/bas-lookup?numbers=${encodeURIComponent(accountNumbers.join(','))}`) + .then((r) => r.json()) + .then((body) => { + if (cancelled) return + setRows((body?.data as BasLookupRow[]) || []) + }) + .catch(() => { + if (cancelled) return + setRows(accountNumbers.map((n) => ({ account_number: n, account_name: null, known: false }))) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, accountNumbers]) + + const knownRows = rows.filter((r) => r.known) + const unknownRows = rows.filter((r) => !r.known) + // Disable confirm when any entered number isn't a valid BAS account: activating + // only the knowns would leave the unknowns to fail again on retry. + const canConfirm = knownRows.length > 0 && unknownRows.length === 0 && !submitting + + async function handleConfirm() { + setSubmitting(true) + try { + await onConfirm() + } finally { + setSubmitting(false) + } + } + + return ( + { if (!next) onCancel() }}> + + + Aktivera konton + + {knownRows.length > 0 + ? 'Följande konton behöver aktiveras i din kontoplan innan bokföringen kan slutföras.' + : 'Inga giltiga BAS-konton att aktivera.'} + + + +
+ {loading && ( +
+ + Hämtar kontouppgifter... +
+ )} + + {!loading && knownRows.length > 0 && ( +
    + {knownRows.map((r) => ( +
  • + {r.account_number} + {r.account_name} +
  • + ))} +
+ )} + + {!loading && unknownRows.length > 0 && ( +
+

Okända konton:

+

{unknownRows.map((r) => r.account_number).join(', ')}

+

+ Dessa nummer finns inte i BAS-katalogen och kan inte aktiveras automatiskt. Kontrollera inmatningen. +

+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index a04df896..d0e64c84 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -16,6 +16,11 @@ import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' +import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' +import { + useSubmitWithAccountActivation, + throwOnStructuredError, +} from '@/lib/hooks/use-submit-with-account-activation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' @@ -273,9 +278,9 @@ export default function JournalEntryForm({ setShowReview(true) } - const handleConfirm = async () => { - setIsSubmitting(true) - + // Inner submit: builds payload, POSTs, throws a structured error on failure + // (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART). + const postJournalEntry = useCallback(async () => { let currencyMetaApplied = false const entryLines: CreateJournalEntryLineInput[] = lines .filter((l) => l.account_number && (l.debit_amount || l.credit_amount)) @@ -287,15 +292,11 @@ export default function JournalEntryForm({ line_description: l.line_description || undefined, } - // Attach currency metadata from pre-populated data (e.g. transaction flow) if (l.currency) { base.currency = l.currency if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate - } - // Attach currency metadata from the entry-level currency selector (manual flow) - // Applied to the first bank/cash account (class 19xx) only - else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) { + } else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) { base.currency = entryCurrency base.amount_in_currency = computedForeignAmount base.exchange_rate = rate @@ -306,7 +307,6 @@ export default function JournalEntryForm({ }) const url = submitUrl ?? '/api/bookkeeping/journal-entries' - const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -321,17 +321,17 @@ export default function JournalEntryForm({ lines: entryLines, }), }) + return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } + }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) - const result = await res.json() + const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = + useSubmitWithAccountActivation(postJournalEntry) + + const handleConfirm = async () => { + setIsSubmitting(true) + try { + const result = await runSubmit() - if (result.error) { - toast({ - title: 'Kunde inte skapa verifikation', - description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }), - variant: 'destructive', - }) - } else { - // Link uploaded documents to the new journal entry (non-blocking) const journalEntryId = result.data?.id ?? result.journal_entry_id if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) @@ -361,7 +361,6 @@ export default function JournalEntryForm({ description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`, }) setShowReview(false) - // Reset form setDescription('') setNotes('') setUploadedFiles([]) @@ -373,9 +372,20 @@ export default function JournalEntryForm({ if (journalEntryId) { onEntryCreated?.(journalEntryId) } + } catch (err) { + if (err instanceof Error && err.message === 'cancelled') { + // User dismissed the activation dialog — no toast needed + } else { + const anyErr = err as { body?: unknown; status?: number } + toast({ + title: 'Kunde inte skapa verifikation', + description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), + variant: 'destructive', + }) + } + } finally { + setIsSubmitting(false) } - - setIsSubmitting(false) } const formContent = ( @@ -770,6 +780,13 @@ export default function JournalEntryForm({ )} + + { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ - in: vi.fn().mockResolvedValue({ - data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], - error: null, + in: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], + error: null, + }), }), }), }), @@ -194,9 +196,11 @@ describe('createDraftEntry — date/period cross-validation', () => { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ - in: vi.fn().mockResolvedValue({ - data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], - error: null, + in: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], + error: null, + }), }), }), }), diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index 7a52df88..1283477b 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -172,12 +172,14 @@ describe('createJournalEntry orphan draft cleanup', () => { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ - in: vi.fn().mockResolvedValue({ - data: [ - { account_number: '1930', id: 'acc-1930' }, - { account_number: '1510', id: 'acc-1510' }, - ], - error: null, + in: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [ + { account_number: '1930', id: 'acc-1930' }, + { account_number: '1510', id: 'acc-1510' }, + ], + error: null, + }), }), }), }), @@ -262,12 +264,14 @@ describe('createJournalEntry orphan draft cleanup', () => { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ - in: vi.fn().mockResolvedValue({ - data: [ - { account_number: '1930', id: 'acc-1930' }, - { account_number: '1510', id: 'acc-1510' }, - ], - error: null, + in: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [ + { account_number: '1930', id: 'acc-1930' }, + { account_number: '1510', id: 'acc-1510' }, + ], + error: null, + }), }), }), }), diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index b0886135..b45d7e65 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' +import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' import type { CreateJournalEntryInput, CreateJournalEntryLineInput, @@ -54,21 +55,37 @@ export async function getNextVoucherNumber( } /** - * Resolve account IDs from account numbers for a company + * Resolve account IDs from account numbers for a company. + * + * By default only active accounts are returned — inactive / never-added + * accounts surface as "missing" so callers throw AccountsNotInChartError. + * + * Pass `{ includeInactive: true }` for reversals: the accounts on an already- + * committed entry were legitimately active at commit time, and BFL 5 kap 5§ + * requires storno to be possible even if a user has since deactivated one of + * those accounts. Blocking the reversal would leave the original entry + * uncorrected with no audit trail. */ async function resolveAccountIds( supabase: SupabaseClient, companyId: string, - lines: CreateJournalEntryLineInput[] + lines: CreateJournalEntryLineInput[], + options: { includeInactive?: boolean } = {} ): Promise> { const accountNumbers = [...new Set(lines.map((l) => l.account_number))] - const { data: accounts, error } = await supabase + let query = supabase .from('chart_of_accounts') .select('id, account_number') .eq('company_id', companyId) .in('account_number', accountNumbers) + if (!options.includeInactive) { + query = query.eq('is_active', true) + } + + const { data: accounts, error } = await query + if (error) { throw new Error(`Failed to resolve account IDs: ${error.message}`) } @@ -178,9 +195,7 @@ export async function createDraftEntry( const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))] const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num)) if (missingAccounts.length > 0) { - throw new Error( - `Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}` - ) + throw new AccountsNotInChartError(missingAccounts) } // Insert journal entry header as draft (voucher_number = 0, will be assigned on commit) @@ -382,16 +397,17 @@ export async function reverseEntry( original.voucher_series || 'A' ) - // Resolve account IDs - const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines) + // Resolve account IDs — include inactive rows. The accounts on the + // original committed entry were active at commit time; if the user has + // since toggled one off, the storno must still be allowed to go through + // (BFL 5 kap 5§). Only a truly missing chart row (rare: would require + // the row to have been deleted) still throws AccountsNotInChartError. + const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines, { includeInactive: true }) - // Validate all account numbers resolved to IDs const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))] const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num)) if (missingReversalAccounts.length > 0) { - throw new Error( - `Account(s) not found in chart of accounts: ${missingReversalAccounts.join(', ')}` - ) + throw new AccountsNotInChartError(missingReversalAccounts) } // Create reversal entry with reverses_id link diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts new file mode 100644 index 00000000..e9329667 --- /dev/null +++ b/lib/bookkeeping/errors.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server' + +export const ACCOUNTS_NOT_IN_CHART = 'ACCOUNTS_NOT_IN_CHART' as const + +export class AccountsNotInChartError extends Error { + readonly code = ACCOUNTS_NOT_IN_CHART + readonly accountNumbers: string[] + + constructor(accountNumbers: string[]) { + const sorted = [...new Set(accountNumbers)].sort() + super(`Accounts not enabled in chart of accounts: ${sorted.join(', ')}`) + this.name = 'AccountsNotInChartError' + this.accountNumbers = sorted + } +} + +export function isAccountsNotInChartError(err: unknown): err is AccountsNotInChartError { + return err instanceof AccountsNotInChartError +} + +/** + * Build a structured 400 response for AccountsNotInChartError. + * API routes catch the typed error and return this so the frontend can + * open ActivateAccountsDialog and retry the request after activation. + */ +export function accountsNotInChartResponse(err: AccountsNotInChartError) { + return NextResponse.json( + { + error: { + code: err.code, + message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`, + account_numbers: err.accountNumbers, + }, + }, + { status: 400 } + ) +} diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 63eb647d..264e1b00 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -6,6 +6,7 @@ import type { JournalEntryLine, } from '@/types' import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' /** * Storno Service - 3-step correction flow per Bokföringslagen @@ -161,12 +162,13 @@ export async function correctEntry( original.voucher_series || 'A' ) - // Resolve account IDs for corrected lines + // Resolve account IDs for corrected lines — only active rows count const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] const { data: accounts } = await supabase .from('chart_of_accounts') .select('id, account_number') .eq('company_id', companyId) + .eq('is_active', true) .in('account_number', accountNumbers) const accountIdMap = new Map() @@ -177,9 +179,7 @@ export async function correctEntry( // Validate all account numbers resolved to IDs const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num)) if (missingAccounts.length > 0) { - throw new Error( - `Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}` - ) + throw new AccountsNotInChartError(missingAccounts) } const { data: newEntry, error: correctedError } = await supabase diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index ee1e454e..81d4948f 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -199,6 +199,18 @@ export function getErrorMessage( if (typeof error === 'object' && error !== null) { const obj = error as Record + // Structured application error: { error: { code, message, ... } } + if (typeof obj.error === 'object' && obj.error !== null) { + const structured = obj.error as { code?: unknown; message?: unknown; account_numbers?: unknown } + if (structured.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) { + const numbers = structured.account_numbers as string[] + return `Följande konton behöver aktiveras: ${numbers.join(', ')}` + } + if (typeof structured.message === 'string' && structured.message.trim()) { + return structured.message + } + } + // Try Zod validation errors const zodMessage = tryParseZodErrors(obj) if (zodMessage) return zodMessage diff --git a/lib/hooks/use-submit-with-account-activation.ts b/lib/hooks/use-submit-with-account-activation.ts new file mode 100644 index 00000000..43d39aff --- /dev/null +++ b/lib/hooks/use-submit-with-account-activation.ts @@ -0,0 +1,127 @@ +'use client' + +import { useCallback, useState } from 'react' + +/** + * Wraps an async submit function so that responses of the form + * `{ error: { code: 'ACCOUNTS_NOT_IN_CHART', account_numbers } }` trigger a + * dialog-and-retry flow instead of surfacing as a generic error. + * + * The consumer renders bound to `dialog.open`, + * `dialog.accountNumbers`, `confirm`, and `cancel`. On confirm the missing + * accounts are activated and the original submit runs again automatically. + * + * The submit fn should throw with a cause object containing the parsed + * response body so we can read the structured error. Convention: + * throw Object.assign(new Error(body.error.message), { body, status }) + */ + +interface DialogState { + open: boolean + accountNumbers: string[] +} + +interface StructuredError { + code?: string + account_numbers?: string[] + message?: string +} + +function extractAccountsNotInChart(err: unknown): string[] | null { + if (typeof err !== 'object' || err === null) return null + const anyErr = err as { body?: { error?: StructuredError } } + const structured = anyErr.body?.error + if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) { + return structured.account_numbers + } + return null +} + +export function useSubmitWithAccountActivation( + submit: () => Promise +) { + const [dialog, setDialog] = useState({ open: false, accountNumbers: [] }) + const [pendingResolve, setPendingResolve] = useState void)>(null) + const [pendingReject, setPendingReject] = useState void)>(null) + + const runSubmit = useCallback(async (): Promise => { + try { + return await submit() + } catch (err) { + const missing = extractAccountsNotInChart(err) + if (!missing) throw err + + // Open dialog and wait for the user to confirm or cancel. + return new Promise((resolve, reject) => { + setPendingResolve(() => (value: T | null) => { + if (value === null) reject(new Error('cancelled')) + else resolve(value) + }) + setPendingReject(() => reject) + setDialog({ open: true, accountNumbers: missing }) + }) + } + }, [submit]) + + const confirm = useCallback(async () => { + const numbers = dialog.accountNumbers + try { + const res = await fetch('/api/bookkeeping/accounts/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_numbers: numbers }), + }) + const body = await res.json().catch(() => ({})) + if (!res.ok) { + throw Object.assign(new Error('Kunde inte aktivera konton'), { body, status: res.status }) + } + // Retry the original submit + const result = await submit() + setDialog({ open: false, accountNumbers: [] }) + pendingResolve?.(result as T | null) + setPendingResolve(null) + setPendingReject(null) + } catch (err) { + setDialog({ open: false, accountNumbers: [] }) + pendingReject?.(err) + setPendingResolve(null) + setPendingReject(null) + } + }, [dialog.accountNumbers, submit, pendingResolve, pendingReject]) + + const cancel = useCallback(() => { + setDialog({ open: false, accountNumbers: [] }) + pendingReject?.(new Error('cancelled')) + setPendingResolve(null) + setPendingReject(null) + }, [pendingReject]) + + return { runSubmit, dialog, confirm, cancel } +} + +/** + * Small helper: parse a Response and throw a shaped error that + * useSubmitWithAccountActivation can recognize. Callers use this inside + * their submit fn so the hook can pick up ACCOUNTS_NOT_IN_CHART. + */ +export async function throwOnStructuredError(response: Response): Promise { + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw Object.assign(new Error(extractMessage(body) || `HTTP ${response.status}`), { + body, + status: response.status, + }) + } + return body +} + +function extractMessage(body: unknown): string | null { + if (typeof body !== 'object' || body === null) return null + const obj = body as { error?: unknown } + if (typeof obj.error === 'string') return obj.error + if (typeof obj.error === 'object' && obj.error !== null) { + const e = obj.error as { message?: unknown } + if (typeof e.message === 'string') return e.message + } + return null +}