Prompt to activate missing BAS accounts at commit (#308)
* feat: prompt to activate missing BAS accounts at commit
Booking to an account not in the active chart previously threw a
generic 400 "Account(s) not found: 5010" and the user had to leave
the form to enable the account via /bookkeeping > BAS-katalog.
- New AccountsNotInChartError thrown from resolveAccountIds in the
engine (and the parallel resolver in core/storno-service). The
query also now filters on is_active=true, so deactivated accounts
are treated the same as never-added ones.
- API routes that call the engine (journal-entries, reverse, correct,
transactions/book + match-invoice + match-supplier-invoice +
uncategorize, invoices/mark-paid, supplier-invoices + mark-paid +
credit, salary/runs/correct, import/opening-balance/execute,
pending-operations/commit) catch the typed error and return a
structured 400: { error: { code: ACCOUNTS_NOT_IN_CHART,
account_numbers, message } }.
- /api/bookkeeping/accounts/activate now also reactivates rows that
already exist but are is_active=false, not only INSERTs. Returns
{ activated, reactivated, skipped, unknown }.
- New GET /api/bookkeeping/accounts/bas-lookup?numbers=... resolves
BAS names client-side so the dialog can show "5010 · Lokalhyra"
without bundling the full 1,276-account catalog.
- ActivateAccountsDialog lists the missing accounts (BAS names + any
unknown non-BAS numbers) and confirms with a single action.
- useSubmitWithAccountActivation wraps an async submit: on
ACCOUNTS_NOT_IN_CHART it opens the dialog, activates on confirm,
then retries the original submit so the user never re-enters data.
- AccountCombobox accepts any 4-digit numeric value, not just items
from the active chart — the activation dialog handles the rest.
- JournalEntryForm wired to the hook + dialog. Other submit surfaces
now surface a clear Swedish message ("Följande konton behöver
aktiveras: …") via getErrorMessage; wiring the dialog into those
is an additive follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sync CLAUDE.md with current codebase state
Catch-up on growth since the last CLAUDE.md revision:
- Integrations list now includes AWS Bedrock, Upstash Redis,
Google Drive, Recharts, PDF.js, @react-pdf/renderer, xlsx,
fuse.js, ics.
- Extension table reflects cloud-backup enabled; adds
inbox-smart-match and example-logger; reorders to match current
extensions.config.json.
- Updated counts: 36 event types (was 30+), 35 MCP tools (was 26),
~60 tables (was ~47), 118 migrations (was 93), 19 report
endpoints (was 16), 20 report generators (was 17).
- lib/ directory table now covers salary, providers,
company-lookup, processing-history, support.ts; removes the
deleted settings/ subdir.
- App routes table adds /salary/*, /help, /settings/salary,
/settings/backup.
- API endpoints table adds /api/salary/*, /api/support/contact,
/api/account/delete, /api/audit-trail/*, /api/log,
/api/currency/rate, top-level extension routes.
- Tables section adds Salary, Third-party providers, Inbox &
Migration groups; removes salary_payments (replaced by
salary_runs + salary_line_items).
- Skills list updated to enumerate the Swedish domain skills by
name instead of the old single /swedish-bookkeeping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback on account activation
Seven fixes based on Greptile + Swedish compliance review on #308.
- ActivateAccountsDialog: disable the confirm button when any
entered number isn't a valid BAS account. Previously activation
would succeed for the knowns and the retry would immediately
fail again on the unknowns, giving a confusing double-toast UX.
- pending-operations/commit: revert commitSendInvoice and
commitMarkInvoiceSent to swallow AccountsNotInChartError
silently. The prior PR upgrade made these blocking, which
regressed invoice delivery for users whose AR accounts are
inactive — and since the activation dialog isn't wired into
those flows yet, there's no one-click recovery. The silent
catches now append an InvoiceJournalEntrySkipped event to
processing_history so the missing verifikation is actionable
in audit trails rather than silently understating the
momsdeklaration (revenue / utgående moms unposted).
- engine.reverseEntry: resolve account IDs with includeInactive=true
so storno of an already-committed entry goes through even when
the user has since deactivated one of its accounts. Blocking
the reversal would leave the original entry uncorrected in
violation of BFL 5 kap 5§ (rättelse must be documented). The
default (includeInactive=false) still applies to createDraftEntry
so new bookings to inactive accounts continue to trigger the
activation dialog.
- supplier-invoices POST + credit: roll back the just-inserted
supplier_invoices row (items cascade-delete) on any JE failure,
not only AccountsNotInChartError. An orphan supplier_invoices
row without a registration / credit JE leaves leverantörsskuld
(2440) and ingående moms (2641) unposted — a silent
understatement / overstatement in the momsdeklaration (ML
2023:200 / BFL 5 kap). The catch now returns a clear Swedish
error message for non-activation failures (typically period
lock or DB error) instead of silently logging.
Test mocks for chart_of_accounts updated for the new query chain
(eq.in.eq instead of eq.eq.in after the is_active conditional).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
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
4cd0a55761
commit
adf58a51c0
@@ -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/<name>/`, 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/<name>/`, 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)
|
||||
|
||||
|
||||
@@ -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<string, boolean>(
|
||||
(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<ReturnType<typeof buildInsertRow>> = []
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown>; 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) {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -142,8 +142,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hittade inget konto som matchar.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
{/^\d{4}$/.test(search.trim()) ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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> | 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<BasLookupRow[]>([])
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Aktivera konton</DialogTitle>
|
||||
<DialogDescription>
|
||||
{knownRows.length > 0
|
||||
? 'Följande konton behöver aktiveras i din kontoplan innan bokföringen kan slutföras.'
|
||||
: 'Inga giltiga BAS-konton att aktivera.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Hämtar kontouppgifter...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && knownRows.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border">
|
||||
{knownRows.map((r) => (
|
||||
<li key={r.account_number} className="flex items-baseline gap-3 px-3 py-2">
|
||||
<span className="font-mono text-foreground w-14 shrink-0">{r.account_number}</span>
|
||||
<span className="truncate">{r.account_name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && unknownRows.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<p className="font-medium">Okända konton:</p>
|
||||
<p className="mt-1 font-mono">{unknownRows.map((r) => r.account_number).join(', ')}</p>
|
||||
<p className="mt-1 text-destructive/80">
|
||||
Dessa nummer finns inte i BAS-katalogen och kan inte aktiveras automatiskt. Kontrollera inmatningen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={submitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Aktiverar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Aktivera och bokför
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActivateAccountsDialog
|
||||
open={activationDialog.open}
|
||||
accountNumbers={activationDialog.accountNumbers}
|
||||
onConfirm={confirmActivation}
|
||||
onCancel={cancelActivation}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showReview}
|
||||
onOpenChange={setShowReview}
|
||||
|
||||
@@ -142,9 +142,11 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
+28
-12
@@ -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<Map<string, string>> {
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>()
|
||||
@@ -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
|
||||
|
||||
@@ -199,6 +199,18 @@ export function getErrorMessage(
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 <ActivateAccountsDialog> 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<T>(
|
||||
submit: () => Promise<T>
|
||||
) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, accountNumbers: [] })
|
||||
const [pendingResolve, setPendingResolve] = useState<null | ((value: T | null) => void)>(null)
|
||||
const [pendingReject, setPendingReject] = useState<null | ((err: unknown) => void)>(null)
|
||||
|
||||
const runSubmit = useCallback(async (): Promise<T> => {
|
||||
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<T>((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<unknown> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user