From 5725c25bf17e6afc1b2f8da3c9e3af31d801253b Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 6 May 2026 11:12:02 +0200 Subject: [PATCH] Logs/improved logging (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add create_transactions tool with /pending approval gate New MCP tool gnubok_create_transactions stages 1–10 transactions per call as pending_operations of type create_transaction (risk: medium). Each item becomes its own card on /pending; on confirm, the executor inserts the row into transactions with import_source='mcp' so MCP-staged ingestion is distinguishable from PSD2 sync. Designed for skill workflows that pull external data (e.g., Airtable) and want the user to gate the writes. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bas): strip concatenated group headers from corrupted account names A chart-data import bug had glued the next group's header onto the last account in each preceding group across all eight bas-data class files (e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names surface in transaction dropdowns, ledgers, SIE exports and årsredovisning, and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet (6999) accounts specifically. - Cleans 69 account_name and 64 description fields across class-1..8 files - Adds a regression test asserting no name contains a concatenated header - Ships an idempotent safety-net migration that updates already-seeded chart_of_accounts rows, gated on the corrupted string so user customizations are preserved Co-Authored-By: Claude Opus 4.7 (1M context) * feat(errors): add structured error codes and handling for various operations - Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application. - Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors. - Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints. - Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations. - Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping. - Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry. * Refactor supplier API routes to use context-based logging and error handling - Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`. - Enhanced error handling to provide structured error responses for supplier creation and listing. - Updated logging to include request IDs for better traceability. - Introduced new error codes for supplier-related operations. - Refactored tax deadlines cron job to utilize context and improved error handling. - Updated ESLint configuration to enforce logging practices across API and lib directories. - Enhanced arcim migration extension with structured error handling and logging. - Added classification for provider errors to improve user-facing error messages. - Introduced request ID in extension context for better log correlation. * fix(route-context): update DynamicParams type for improved type safety in route handlers * feat(transactions): add 'create_transaction' operation to PendingOperationType * fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function * fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/bookkeeping/[id]/page.tsx | 3 +- app/(dashboard)/customers/page.tsx | 3 +- app/(dashboard)/expenses/[id]/page.tsx | 9 +- app/(dashboard)/expenses/new/page.tsx | 2 +- app/(dashboard)/import/page.tsx | 48 +- app/(dashboard)/invoices/new/page.tsx | 2 +- app/(dashboard)/pending/page.tsx | 27 + .../supplier-invoices/[id]/page.tsx | 11 +- app/(dashboard)/suppliers/[id]/page.tsx | 6 +- app/(dashboard)/transactions/page.tsx | 8 +- .../[id]/currency-revaluation/route.ts | 123 +- .../fiscal-periods/[id]/lock/route.ts | 62 +- .../[id]/opening-balances/route.ts | 100 +- .../fiscal-periods/[id]/year-end/route.ts | 131 +- app/api/customers/[id]/route.ts | 286 ++-- app/api/customers/route.ts | 196 +-- app/api/deadlines/status/cron/route.ts | 57 +- app/api/documents/[id]/link/route.ts | 92 +- app/api/documents/route.ts | 167 ++- app/api/documents/verify/cron/route.ts | 142 +- app/api/events/cleanup/cron/route.ts | 53 +- .../cloud-backup/auto-sync/cron/route.ts | 44 +- .../enable-banking/sync/cron/route.ts | 51 +- app/api/extensions/ext/[...path]/route.ts | 124 +- .../push-notifications/cron/route.ts | 61 +- app/api/idempotency/cleanup/cron/route.ts | 30 +- app/api/import/bank-file/execute/route.ts | 233 ++-- app/api/import/bank-file/parse/route.ts | 163 ++- .../opening-balance/__tests__/execute.test.ts | 12 +- .../import/opening-balance/execute/route.ts | 415 +++--- app/api/import/opening-balance/parse/route.ts | 93 +- app/api/import/sie/[id]/replace/route.ts | 55 +- app/api/import/sie/execute/route.ts | 388 +++--- app/api/import/sie/parse/route.ts | 248 ++-- .../[id]/mark-paid/__tests__/route.test.ts | 8 +- app/api/invoices/[id]/mark-paid/route.ts | 366 +++-- .../[id]/send/__tests__/route.test.ts | 14 +- app/api/invoices/[id]/send/route.ts | 262 ++-- app/api/invoices/__tests__/route.test.ts | 15 +- app/api/invoices/reminders/cron/route.ts | 76 +- app/api/invoices/route.ts | 497 ++++--- app/api/reports/balance-sheet/route.ts | 79 +- app/api/reports/general-ledger/route.ts | 48 +- app/api/reports/income-statement/route.ts | 80 +- app/api/reports/ink2/route.ts | 118 +- app/api/reports/ne-bilaga/route.ts | 98 +- app/api/reports/sie-export/route.ts | 88 +- app/api/reports/vat-declaration/route.ts | 188 ++- app/api/salary/runs/[id]/book/route.ts | 223 ++- app/api/salary/runs/[id]/calculate/route.ts | 59 +- app/api/salary/runs/route.ts | 166 +-- app/api/sandbox/cleanup/cron/route.ts | 50 +- app/api/settings/api-keys/route.ts | 175 ++- .../[id]/approve/__tests__/route.test.ts | 4 +- .../supplier-invoices/[id]/approve/route.ts | 103 +- .../supplier-invoices/[id]/credit/route.ts | 319 ++--- .../[id]/mark-paid/__tests__/route.test.ts | 6 +- .../supplier-invoices/[id]/mark-paid/route.ts | 341 +++-- .../supplier-invoices/__tests__/route.test.ts | 35 +- app/api/supplier-invoices/route.ts | 529 ++++--- app/api/suppliers/[id]/route.ts | 272 ++-- app/api/suppliers/route.ts | 145 +- app/api/tax-deadlines/cron/route.ts | 52 +- .../[id]/categorize/__tests__/route.test.ts | 6 +- app/api/transactions/[id]/categorize/route.ts | 713 +++++----- .../match-invoice/__tests__/route.test.ts | 18 +- .../transactions/[id]/match-invoice/route.ts | 510 +++---- .../[id]/match-supplier-invoice/route.ts | 400 +++--- eslint.config.mjs | 23 +- extensions/general/arcim-migration/index.ts | 164 ++- .../__tests__/create-transactions.test.ts | 104 ++ extensions/general/mcp-server/server.ts | 109 ++ lib/__tests__/logger.test.ts | 85 ++ lib/api/validate.ts | 30 + lib/api/with-cron-context.ts | 129 ++ lib/api/with-route-context.ts | 157 +++ lib/auth/api-keys.ts | 1 + .../__tests__/bas-reference.test.ts | 8 + lib/bookkeeping/bas-data/class-1-assets.ts | 38 +- .../bas-data/class-2-equity-liabilities.ts | 38 +- lib/bookkeeping/bas-data/class-3-revenue.ts | 24 +- lib/bookkeeping/bas-data/class-4-purchases.ts | 36 +- .../bas-data/class-5-external-expenses.ts | 38 +- .../bas-data/class-6-other-external.ts | 34 +- lib/bookkeeping/bas-data/class-7-personnel.ts | 34 +- lib/bookkeeping/bas-data/class-8-financial.ts | 24 +- .../supplier-invoice-handler.test.ts | 21 +- .../__tests__/structured-errors.test.ts | 135 ++ lib/errors/get-error-message.ts | 8 + lib/errors/get-structured-error.ts | 324 ++++- lib/errors/structured-errors.ts | 1232 +++++++++++++++++ lib/events/bus.ts | 38 +- lib/extensions/context-factory.ts | 22 +- lib/extensions/types.ts | 6 + lib/hooks/use-error-toast.ts | 114 ++ lib/logger.ts | 218 ++- .../__tests__/executors.test.ts | 68 + lib/pending-operations/commit.ts | 49 + lib/pending-operations/risk-tiers.ts | 1 + lib/providers/with-provider-call.ts | 208 +++ ...160000_fix_corrupted_bas_account_names.sql | 102 ++ types/index.ts | 2 + 102 files changed, 7887 insertions(+), 5245 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/create-transactions.test.ts create mode 100644 lib/__tests__/logger.test.ts create mode 100644 lib/api/with-cron-context.ts create mode 100644 lib/api/with-route-context.ts create mode 100644 lib/errors/__tests__/structured-errors.test.ts create mode 100644 lib/errors/structured-errors.ts create mode 100644 lib/hooks/use-error-toast.ts create mode 100644 lib/providers/with-provider-call.ts create mode 100644 supabase/migrations/20260505160000_fix_corrupted_bas_account_names.sql diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 1a31567c..ad7248bf 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -15,6 +15,7 @@ import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialo import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { JournalEntry, JournalEntryLine } from '@/types' export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -89,7 +90,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i }) router.push('/bookkeeping') } else { - toast({ title: 'Kunde inte radera', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte radera', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) setShowDeleteConfirm(false) } } catch { diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index e0a3c799..643e9eca 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { Plus, Search, Users, Lock } from 'lucide-react' import CustomerForm from '@/components/customers/CustomerForm' import { EmptyCustomers } from '@/components/ui/empty-state' @@ -82,7 +83,7 @@ export default function CustomersPage() { if (!response.ok) { toast({ title: 'Kunde inte skapa kund', - description: result.error || 'Försök igen.', + description: getErrorMessage(result, { context: 'customer' }), variant: 'destructive', }) } else { diff --git a/app/(dashboard)/expenses/[id]/page.tsx b/app/(dashboard)/expenses/[id]/page.tsx index e5dfd43c..1f894a6c 100644 --- a/app/(dashboard)/expenses/[id]/page.tsx +++ b/app/(dashboard)/expenses/[id]/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react' import Link from 'next/link' import { AccountNumber } from '@/components/ui/account-number' @@ -78,7 +79,7 @@ export default function ExpenseDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte godkänna', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte godkänna', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Godkänd', description: 'Utgiften har godkänts' }) fetchInvoice() @@ -95,7 +96,7 @@ export default function ExpenseDetailPage() { }) const result = await res.json() if (!res.ok) { - toast({ title: 'Betalning misslyckades', description: result.error, variant: 'destructive' }) + toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad', @@ -119,7 +120,7 @@ export default function ExpenseDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte kreditera', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte kreditera', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Kreditfaktura registrerad' }) fetchInvoice() @@ -138,7 +139,7 @@ export default function ExpenseDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte ta bort', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte ta bort', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Borttagen' }) router.push('/expenses') diff --git a/app/(dashboard)/expenses/new/page.tsx b/app/(dashboard)/expenses/new/page.tsx index dd8ff5c2..4772462f 100644 --- a/app/(dashboard)/expenses/new/page.tsx +++ b/app/(dashboard)/expenses/new/page.tsx @@ -227,7 +227,7 @@ export default function NewExpensePage() { const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte skapa leverantör', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte skapa leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { const created = result.data as Supplier setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 9db5042c..1226d36e 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, Scale } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' @@ -362,24 +363,39 @@ function SIEImportWizard() { const data = await res.json() if (!res.ok) { - const type = data.error as typeof errorType - if (type === 'duplicate' || type === 'duplicate_period') { - setErrorType(type) - setError(data.message) - if (data.importId) { - setDuplicateImportId(data.importId) + const code = data?.error?.code as string | undefined + const message = getErrorMessage(data) + const details = (data?.error?.details ?? {}) as { + importId?: string + errors?: string[] + warnings?: string[] + } + if (code === 'SIE_DUPLICATE_FILE' || code === 'SIE_DUPLICATE_PERIOD') { + const isPeriod = code === 'SIE_DUPLICATE_PERIOD' + setErrorType(isPeriod ? 'duplicate_period' : 'duplicate') + setError(message) + if (details.importId) { + setDuplicateImportId(details.importId) } - toast({ title: type === 'duplicate' ? 'Filen har redan importerats' : 'Överlappande räkenskapsår', description: data.message, variant: 'destructive' }) - } else if (type === 'validation') { + toast({ + title: isPeriod ? 'Överlappande räkenskapsår' : 'Filen har redan importerats', + description: message, + variant: 'destructive', + }) + } else if (code === 'SIE_PARSE_VALIDATION_FAILED') { setErrorType('validation') - setError(data.message || 'SIE-filen innehåller valideringsfel.') - setValidationErrors(data.errors || []) - setValidationWarnings(data.warnings || []) - toast({ title: 'Valideringsfel i SIE-filen', description: `${(data.errors || []).length} fel hittades som måste åtgärdas.`, variant: 'destructive' }) + setError(message) + setValidationErrors(details.errors || []) + setValidationWarnings(details.warnings || []) + toast({ + title: 'Valideringsfel i SIE-filen', + description: `${(details.errors || []).length} fel hittades som måste åtgärdas.`, + variant: 'destructive', + }) } else { setErrorType('parse') - setError(data.message || data.error || 'Kunde inte tolka filen.') - toast({ title: 'Kunde inte läsa filen', description: data.message || data.error || 'Kontrollera att filen är en giltig SIE-fil.', variant: 'destructive' }) + setError(message) + toast({ title: 'Kunde inte läsa filen', description: message, variant: 'destructive' }) } return } @@ -433,7 +449,7 @@ function SIEImportWizard() { const data = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte ersätta import', description: data.error || 'Ett fel uppstod', variant: 'destructive' }) + toast({ title: 'Kunde inte ersätta import', description: getErrorMessage(data), variant: 'destructive' }) return } @@ -498,7 +514,7 @@ function SIEImportWizard() { const data = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte skapa konton', description: data.error || 'Försök igen.', variant: 'destructive' }) + toast({ title: 'Kunde inte skapa konton', description: getErrorMessage(data), variant: 'destructive' }) return } diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index e003f007..a490809a 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -209,7 +209,7 @@ export default function NewInvoicePage() { if (!response.ok) { toast({ title: 'Kunde inte skapa kund', - description: result.error || 'Försök igen.', + description: getErrorMessage(result, { context: 'customer' }), variant: 'destructive', }) } else { diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index a92484d3..6f03f0d5 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -26,6 +26,7 @@ const operationLabels: Record }) { ) } +function CreateTransactionPreview({ data }: { data: Record }) { + const amount = data.amount as number + const currency = (data.currency as string) || 'SEK' + + return ( +
+ Datum + {String(data.date ?? '')} + Beskrivning + {String(data.description ?? '')} + Belopp + + {formatCurrency(amount, currency)} + + {data.external_id ? ( + <> + Extern referens + {String(data.external_id)} + + ) : null} +
+ ) +} + function GenericPreview({ data }: { data: Record }) { const entries = Object.entries(data).filter(([, v]) => v != null && v !== '') return ( @@ -162,6 +187,8 @@ function OperationPreview({ op }: { op: PendingOperation }) { return case 'create_invoice': return + case 'create_transaction': + return default: return } diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index bdcb1ec3..9c7adef2 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import Link from 'next/link' @@ -78,7 +79,7 @@ export default function SupplierInvoiceDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Godkännande misslyckades', description: result.error, variant: 'destructive' }) + toast({ title: 'Godkännande misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Godkänd', description: 'Fakturan har godkänts' }) fetchInvoice() @@ -95,7 +96,7 @@ export default function SupplierInvoiceDetailPage() { }) const result = await res.json() if (!res.ok) { - toast({ title: 'Betalning misslyckades', description: result.error, variant: 'destructive' }) + toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad', @@ -119,7 +120,7 @@ export default function SupplierInvoiceDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kreditering misslyckades', description: result.error, variant: 'destructive' }) + toast({ title: 'Kreditering misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Kreditfaktura registrerad' }) fetchInvoice() @@ -138,7 +139,7 @@ export default function SupplierInvoiceDetailPage() { const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte ta bort faktura', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte ta bort faktura', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) } else { toast({ title: 'Borttagen' }) router.push('/supplier-invoices') @@ -160,7 +161,7 @@ export default function SupplierInvoiceDetailPage() { if (!res.ok) { toast({ title: 'Kunde inte ångra kreditering', - description: result.error || 'Försök igen', + description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive', }) } else { diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 05525a6e..5aa99e35 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { ArrowLeft, Edit, Trash2, FileText, Lock } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import SupplierForm from '@/components/suppliers/SupplierForm' @@ -70,8 +71,7 @@ export default function SupplierDetailPage() { }) const result = await res.json() if (!res.ok) { - const fieldErrors = result.errors?.map((e: { field: string; message: string }) => `${e.field}: ${e.message}`).join(', ') - toast({ title: 'Kunde inte uppdatera leverantör', description: fieldErrors || result.error || 'Försök igen.', variant: 'destructive' }) + toast({ title: 'Kunde inte uppdatera leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { toast({ title: 'Sparat', description: 'Leverantören har uppdaterats' }) setSupplier({ ...result.data, stats: supplier?.stats }) @@ -92,7 +92,7 @@ export default function SupplierDetailPage() { const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' }) const result = await res.json() if (!res.ok) { - toast({ title: 'Kunde inte ta bort leverantör', description: result.error, variant: 'destructive' }) + toast({ title: 'Kunde inte ta bort leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { toast({ title: 'Borttagen', description: 'Leverantören har tagits bort' }) router.push('/suppliers') diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 8ffb9396..ed80fe38 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -374,7 +374,7 @@ export default function TransactionsPage() { }) const result = await response.json() if (!response.ok) { - toast({ title: 'Fakturamatchning misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' }) + toast({ title: 'Fakturamatchning misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' }) setIsConfirmingMatch(false) return } @@ -426,7 +426,7 @@ export default function TransactionsPage() { }) const result = await response.json() if (!response.ok) { - toast({ title: 'Fakturamatchning misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' }) + toast({ title: 'Fakturamatchning misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' }) return false } @@ -510,7 +510,7 @@ export default function TransactionsPage() { const result = await response.json() toast({ title: 'Kunde inte ta bort', - description: result.error || 'Försök igen.', + description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive', }) return @@ -712,7 +712,7 @@ export default function TransactionsPage() { }) const result = await response.json() if (!response.ok) { - toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' }) + toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' }) return null } setExitingIds((prev) => new Set(prev).add(id)) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts index 4234a1fe..1fcf83e3 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts @@ -1,32 +1,19 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { previewCurrencyRevaluation, executeCurrencyRevaluation, } from '@/lib/bookkeeping/currency-revaluation' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -/** - * GET: Preview currency revaluation for a fiscal period - */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +/** GET: preview currency revaluation for a fiscal period. */ +export const GET = withRouteContext( + 'period.fx_revaluation_preview', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - try { - // Fetch period to get closing date const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('*') @@ -35,43 +22,28 @@ export async function GET( .single() if (periodError || !period) { - return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) + return errorResponseFromCode('FX_PERIOD_NOT_FOUND', opLog, { requestId }) } - const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end) - return NextResponse.json({ data: preview }) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to preview currency revaluation' }, - { status: 400 } - ) - } -} + try { + const preview = await previewCurrencyRevaluation(supabase, companyId!, period.period_end) + return NextResponse.json({ data: preview }) + } catch (err) { + opLog.error('fx revaluation preview failed', err as Error) + // Bookkeeping errors flow through errorResponse with their typed codes. + return errorResponse(err, opLog, { requestId }) + } + }, +) -/** - * POST: Execute currency revaluation for a fiscal period - */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +/** POST: execute currency revaluation, creating the period-end FX entry. */ +export const POST = withRouteContext( + 'period.fx_revaluation', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - // Fetch period to get closing date const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('*') @@ -80,26 +52,35 @@ export async function POST( .single() if (periodError || !period) { - return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) + return errorResponseFromCode('FX_PERIOD_NOT_FOUND', opLog, { requestId }) } if (period.is_closed) { - return NextResponse.json({ error: 'Period is already closed' }, { status: 400 }) + return errorResponseFromCode('FX_PERIOD_CLOSED', opLog, { requestId }) } - const result = await executeCurrencyRevaluation(supabase, companyId, period.period_end, id, user.id) + try { + const result = await executeCurrencyRevaluation( + supabase, companyId!, period.period_end, id, user.id, + ) - if (!result) { - return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' }) + if (!result) { + return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' }) + } + + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('fx revaluation execution failed', err as Error) + // CurrencyRevaluationAlreadyExistsError + other typed errors flow through. + const fallback = errorResponse(err, opLog, { requestId }) + if (fallback.status === 500) { + return errorResponseFromCode('FX_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + return fallback } - - return NextResponse.json({ data: result }) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to execute currency revaluation' }, - { status: 400 } - ) - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts index 2a645725..68b41dc0 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts @@ -1,33 +1,37 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { lockPeriod } from '@/lib/core/bookkeeping/period-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'period.lock', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - const period = await lockPeriod(supabase, companyId, user.id, id) - return NextResponse.json({ data: period }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to lock period' }, - { status: 400 } - ) - } -} + try { + const period = await lockPeriod(supabase, companyId!, user.id, id) + return NextResponse.json({ data: period }) + } catch (err) { + opLog.error('failed to lock period', err as Error) + // The service throws plain Error with messages like "Period not found" + // or "Period contains drafts" — translate to envelope codes. + const message = err instanceof Error ? err.message : '' + if (/not found/i.test(message)) { + return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId }) + } + if (/already locked|already closed/i.test(message)) { + return errorResponseFromCode('PERIOD_LOCK_ALREADY_LOCKED', opLog, { requestId }) + } + if (/draft/i.test(message)) { + return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, { + requestId, + details: { reason: message }, + }) + } + return errorResponse(err, opLog, { requestId }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts index 7b7ef234..229ef704 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts @@ -1,67 +1,57 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getOpeningBalances } from '@/lib/reports/opening-balances' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'period.opening_balances', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('period_start, opening_balance_entry_id') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) - const { id } = await params + if (periodError || !period) { + return errorResponseFromCode('OPENING_BAL_PERIOD_NOT_FOUND', opLog, { requestId }) + } - // Fetch the fiscal period - const { data: period, error: periodError } = await supabase - .from('fiscal_periods') - .select('period_start, opening_balance_entry_id') - .eq('id', id) - .eq('company_id', companyId) - .single() + const { balances } = await getOpeningBalances(supabase, companyId!, period) - if (periodError || !period) { - return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) - } + const accountNumbers = Array.from(balances.keys()) - // Get opening balances - const { balances } = await getOpeningBalances(supabase, companyId, period) + if (accountNumbers.length === 0) { + return NextResponse.json({ data: [] }) + } - // Fetch account names for the accounts that have balances - const accountNumbers = Array.from(balances.keys()) + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('company_id', companyId) + .in('account_number', accountNumbers) - if (accountNumbers.length === 0) { - return NextResponse.json({ data: [] }) - } + const accountNameMap = new Map( + (accounts || []).map((a) => [a.account_number, a.account_name]), + ) - const { data: accounts } = await supabase - .from('chart_of_accounts') - .select('account_number, account_name') - .eq('company_id', companyId) - .in('account_number', accountNumbers) + const data = accountNumbers + .sort() + .map((accountNumber) => { + const bal = balances.get(accountNumber)! + const net = Math.round((bal.debit - bal.credit) * 100) / 100 + return { + account_number: accountNumber, + account_name: accountNameMap.get(accountNumber) || accountNumber, + balance: net, + } + }) + .filter((row) => row.balance !== 0) - const accountNameMap = new Map( - (accounts || []).map(a => [a.account_number, a.account_name]) - ) - - // Build response with account names and net balances - const data = accountNumbers - .sort() - .map(accountNumber => { - const bal = balances.get(accountNumber)! - const net = Math.round((bal.debit - bal.credit) * 100) / 100 - return { - account_number: accountNumber, - account_name: accountNameMap.get(accountNumber) || accountNumber, - balance: net, - } - }) - .filter(row => row.balance !== 0) - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts index 8238e187..69fb3c61 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts @@ -1,72 +1,79 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateYearEndReadiness, previewYearEndClosing, executeYearEndClosing, } from '@/lib/core/bookkeeping/year-end-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -/** - * GET: Validate readiness and preview year-end closing - */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +/** GET: validate readiness + preview the year-end entries. */ +export const GET = withRouteContext( + 'period.year_end_preview', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const [validation, preview] = await Promise.all([ + validateYearEndReadiness(supabase, companyId!, user.id, id), + previewYearEndClosing(supabase, companyId!, user.id, id), + ]) + return NextResponse.json({ data: { validation, preview } }) + } catch (err) { + opLog.error('year-end preview failed', err as Error) + const message = err instanceof Error ? err.message : '' + if (/not found/i.test(message)) { + return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId }) + } + return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, { + requestId, + details: { reason: message }, + }) + } + }, +) - const companyId = await requireCompanyId(supabase, user.id) +/** POST: actually run year-end closing. */ +export const POST = withRouteContext( + 'period.year_end', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) - try { - const [validation, preview] = await Promise.all([ - validateYearEndReadiness(supabase, companyId, user.id, id), - previewYearEndClosing(supabase, companyId, user.id, id), - ]) - - return NextResponse.json({ data: { validation, preview } }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to preview year-end' }, - { status: 400 } - ) - } -} - -/** - * POST: Execute year-end closing - */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - const result = await executeYearEndClosing(supabase, companyId, user.id, id) - return NextResponse.json({ data: result }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to execute year-end closing' }, - { status: 400 } - ) - } -} + try { + const result = await executeYearEndClosing(supabase, companyId!, user.id, id) + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('year-end execution failed', err as Error) + const message = err instanceof Error ? err.message : '' + if (/prior.*open/i.test(message)) { + return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { + requestId, + details: { reason: message }, + }) + } + if (/not balanced|unbalanced/i.test(message)) { + return errorResponseFromCode('YEAR_END_UNBALANCED_TRIAL', opLog, { + requestId, + details: { reason: message }, + }) + } + if (/not found/i.test(message)) { + return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId }) + } + // Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED. + const fallback = errorResponse(err, opLog, { requestId }) + if (fallback.status === 500) { + return errorResponseFromCode('YEAR_END_FAILED', opLog, { + requestId, + details: { reason: message }, + }) + } + return fallback + } + }, + { requireWrite: true }, +) diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 82ad0ffd..ae109be9 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -1,199 +1,163 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import { createLogger } from '@/lib/logger' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -const log = createLogger('api/customers/[id]') +export const GET = withRouteContext( + 'customer.get', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ customerId: id }) -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params + const { data, error } = await supabase + .from('customers') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('customers') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error) { - if (error.code === 'PGRST116') { - return NextResponse.json({ error: 'Customer not found' }, { status: 404 }) + if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId }) + } + opLog.error('customer fetch failed', error) + return errorResponseFromCode('INTERNAL_ERROR', opLog, { + requestId, + details: { reason: error.message }, + }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - // Fetch related invoices - const { data: invoices } = await supabase - .from('invoices') - .select('id, invoice_number, invoice_date, due_date, status, total, currency') - .eq('customer_id', id) - .eq('company_id', companyId) - .order('invoice_date', { ascending: false }) + const { data: invoices } = await supabase + .from('invoices') + .select('id, invoice_number, invoice_date, due_date, status, total, currency') + .eq('customer_id', id) + .eq('company_id', companyId) + .order('invoice_date', { ascending: false }) - return NextResponse.json({ - data: { - ...data, - invoices: invoices || [], - }, - }) -} + return NextResponse.json({ data: { ...data, invoices: invoices || [] } }) + }, +) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const PATCH = withRouteContext( + 'customer.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ customerId: id }) - const { - data: { user }, - } = await supabase.auth.getUser() + const result = await validateBody(request, UpdateCustomerSchema, { + log: opLog, + operation: 'customer.update', + }) + if (!result.success) return result.response + const body = result.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const updateData: Record = {} + if (body.name !== undefined) updateData.name = body.name + if (body.customer_type !== undefined) updateData.customer_type = body.customer_type + if (body.email !== undefined) updateData.email = body.email + if (body.phone !== undefined) updateData.phone = body.phone + if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1 + if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2 + if (body.postal_code !== undefined) updateData.postal_code = body.postal_code + if (body.city !== undefined) updateData.city = body.city + if (body.country !== undefined) updateData.country = body.country + if (body.org_number !== undefined) updateData.org_number = body.org_number + if (body.vat_number !== undefined) updateData.vat_number = body.vat_number + if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms + if (body.notes !== undefined) updateData.notes = body.notes - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data, error } = await supabase + .from('customers') + .update(updateData) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, { + requestId, + details: { orgNumber: body.org_number }, + }) + } + opLog.error('customer update failed', error) + return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } - const result = await validateBody(request, UpdateCustomerSchema) - if (!result.success) return result.response - const body = result.data - - const updateData: Record = {} - - if (body.name !== undefined) updateData.name = body.name - if (body.customer_type !== undefined) updateData.customer_type = body.customer_type - if (body.email !== undefined) updateData.email = body.email - if (body.phone !== undefined) updateData.phone = body.phone - if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1 - if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2 - if (body.postal_code !== undefined) updateData.postal_code = body.postal_code - if (body.city !== undefined) updateData.city = body.city - if (body.country !== undefined) updateData.country = body.country - if (body.org_number !== undefined) updateData.org_number = body.org_number - if (body.vat_number !== undefined) updateData.vat_number = body.vat_number - if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms - if (body.notes !== undefined) updateData.notes = body.notes - - const { data, error } = await supabase - .from('customers') - .update(updateData) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Auto-validate VAT number when it changes on an EU business customer (non-blocking) - const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business' - if (body.vat_number !== undefined && isEuBusiness) { - try { - if (body.vat_number) { - const vatResult = await validateVatNumber(body.vat_number) - if (vatResult.valid) { + // Re-run VIES validation when the VAT number changes on an EU business + // customer (non-blocking). + const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business' + if (body.vat_number !== undefined && isEuBusiness) { + try { + if (body.vat_number) { + const vatResult = await validateVatNumber(body.vat_number) + const validatedAt = vatResult.valid ? new Date().toISOString() : null await supabase .from('customers') .update({ - vat_number_validated: true, - vat_number_validated_at: new Date().toISOString(), + vat_number_validated: vatResult.valid, + vat_number_validated_at: validatedAt, }) .eq('id', id) .eq('company_id', companyId) - - data.vat_number_validated = true - data.vat_number_validated_at = new Date().toISOString() + data.vat_number_validated = vatResult.valid + data.vat_number_validated_at = validatedAt } else { await supabase .from('customers') - .update({ - vat_number_validated: false, - vat_number_validated_at: null, - }) + .update({ vat_number_validated: false, vat_number_validated_at: null }) .eq('id', id) .eq('company_id', companyId) - data.vat_number_validated = false data.vat_number_validated_at = null } - } else { - // VAT number cleared - await supabase - .from('customers') - .update({ - vat_number_validated: false, - vat_number_validated_at: null, - }) - .eq('id', id) - .eq('company_id', companyId) - - data.vat_number_validated = false - data.vat_number_validated_at = null + } catch (err) { + opLog.warn('auto-VIES validation failed on customer update', err as Error) } - } catch (err) { - log.warn('Auto-VIES validation failed on customer update:', err) } - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext( + 'customer.delete', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ customerId: id }) - const { - data: { user }, - } = await supabase.auth.getUser() + const { error, count } = await supabase + .from('customers') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('company_id', companyId) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + if (error.code === '23503') { + return errorResponseFromCode('CUSTOMER_HAS_INVOICES', opLog, { requestId }) + } + opLog.error('customer delete failed', error) + return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (count === 0) { + return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { error, count } = await supabase - .from('customers') - .delete({ count: 'exact' }) - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - if (count === 0) { - return NextResponse.json({ error: 'Customer not found' }, { status: 404 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 8bc7461a..48f418a3 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -1,113 +1,113 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import { createLogger } from '@/lib/logger' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { Customer } from '@/types' -const log = createLogger('api/customers') - ensureInitialized() -export async function GET() { - const supabase = await createClient() +export const GET = withRouteContext( + 'customer.list', + async (_request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { data, error } = await supabase + .from('customers') + .select('*') + .eq('company_id', companyId) + .order('name', { ascending: true }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('customers') - .select('*') - .eq('company_id', companyId) - .order('name', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const result = await validateBody(request, CreateCustomerSchema) - if (!result.success) return result.response - const body = result.data - - const { data, error } = await supabase - .from('customers') - .insert({ - user_id: user.id, - company_id: companyId, - name: body.name, - customer_type: body.customer_type, - email: body.email, - phone: body.phone, - address_line1: body.address_line1, - address_line2: body.address_line2, - postal_code: body.postal_code, - city: body.city, - country: body.country || 'Sweden', - org_number: body.org_number, - vat_number: body.vat_number, - default_payment_terms: body.default_payment_terms || 30, - notes: body.notes, - }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Auto-validate VAT number for EU business customers (non-blocking) - if (body.customer_type === 'eu_business' && body.vat_number) { - try { - const vatResult = await validateVatNumber(body.vat_number) - if (vatResult.valid) { - await supabase - .from('customers') - .update({ - vat_number_validated: true, - vat_number_validated_at: new Date().toISOString(), - }) - .eq('id', data.id) - .eq('company_id', companyId) - - data.vat_number_validated = true - data.vat_number_validated_at = new Date().toISOString() - } - } catch (err) { - log.warn('Auto-VIES validation failed on customer create:', err) + if (error) { + log.error('customer list failed', error) + return errorResponse(error, log, { requestId }) } - } - await eventBus.emit({ - type: 'customer.created', - payload: { customer: data as Customer, companyId, userId: user.id }, - }) + return NextResponse.json({ data }) + }, +) - return NextResponse.json({ data }) -} +export const POST = withRouteContext( + 'customer.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, CreateCustomerSchema, { + log, + operation: 'customer.create', + }) + if (!result.success) return result.response + const body = result.data + + const { data, error } = await supabase + .from('customers') + .insert({ + user_id: user.id, + company_id: companyId, + name: body.name, + customer_type: body.customer_type, + email: body.email, + phone: body.phone, + address_line1: body.address_line1, + address_line2: body.address_line2, + postal_code: body.postal_code, + city: body.city, + country: body.country || 'Sweden', + org_number: body.org_number, + vat_number: body.vat_number, + default_payment_terms: body.default_payment_terms || 30, + notes: body.notes, + }) + .select() + .single() + + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', log, { + requestId, + details: { orgNumber: body.org_number }, + }) + } + log.error('customer insert failed', error) + return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, { + requestId, + details: { reason: error.message }, + }) + } + + // Auto-validate VAT number for EU business customers (non-blocking). + if (body.customer_type === 'eu_business' && body.vat_number) { + try { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + await supabase + .from('customers') + .update({ + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), + }) + .eq('id', data.id) + .eq('company_id', companyId) + + data.vat_number_validated = true + data.vat_number_validated_at = new Date().toISOString() + } + } catch (err) { + log.warn('auto-VIES validation failed on customer create', err as Error, { + customerId: data.id, + }) + } + } + + await eventBus.emit({ + type: 'customer.created', + payload: { customer: data as Customer, companyId: companyId!, userId: user.id }, + }) + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/deadlines/status/cron/route.ts b/app/api/deadlines/status/cron/route.ts index aaa7c9d7..f23b548d 100644 --- a/app/api/deadlines/status/cron/route.ts +++ b/app/api/deadlines/status/cron/route.ts @@ -1,51 +1,38 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { updateDeadlineStatuses } from '@/lib/deadlines/status-engine' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' /** - * GET /api/deadlines/status/cron - * Daily cron job to update deadline statuses - * Runs at 06:00 every day - * - * Vercel Cron: "0 6 * * *" + * GET /api/deadlines/status/cron — daily 06:00 UTC. + * Updates deadline statuses across all companies. */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - - // Create a service role client for accessing all user data +export const GET = withCronContext('cron.deadlines_status', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) - try { - const result = await updateDeadlineStatuses(supabase) + const result = await updateDeadlineStatuses(supabase) - console.log( - `Deadline status cron completed: ${result.updated} updated, ` + - `${result.newlyOverdue} newly overdue, ${result.newlyActionNeeded} newly action_needed` - ) + ctx.log.info('deadline status cron summary', { + updated: result.updated, + newlyOverdue: result.newlyOverdue, + newlyActionNeeded: result.newlyActionNeeded, + }) - return NextResponse.json({ - success: true, - updated: result.updated, - newlyOverdue: result.newlyOverdue, - newlyActionNeeded: result.newlyActionNeeded, - }) - } catch (error) { - console.error('Error in deadline status cron:', error) - return NextResponse.json( - { error: 'Failed to update deadline statuses' }, - { status: 500 } - ) - } -} + return NextResponse.json({ + success: true, + updated: result.updated, + newlyOverdue: result.newlyOverdue, + newlyActionNeeded: result.newlyActionNeeded, + }) +}) diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts index 9407cf3b..c2465a4e 100644 --- a/app/api/documents/[id]/link/route.ts +++ b/app/api/documents/[id]/link/route.ts @@ -1,63 +1,57 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { linkToJournalEntry } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() /** - * POST /api/documents/:id/link - * Link a document to a journal entry (verifikation) + * POST /api/documents/[id]/link — link a document to a journal entry. * - * Request body: - * - journal_entry_id: string (required) - * - journal_entry_line_id: string (optional) + * Body: { journal_entry_id: string, journal_entry_line_id?: string } */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext( + 'document.link', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ documentId: id }) - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - try { - const body = await request.json() + const body = await request.json().catch(() => ({})) if (!body.journal_entry_id) { - return NextResponse.json( - { error: 'journal_entry_id is required' }, - { status: 400 } - ) + return errorResponseFromCode('VALIDATION_ERROR', opLog, { + requestId, + details: { field: 'journal_entry_id', reason: 'required' }, + }) } - const document = await linkToJournalEntry( - supabase, - companyId, - id, - body.journal_entry_id, - body.journal_entry_line_id - ) - - return NextResponse.json({ data: document }) - } catch (error) { - console.error('[documents/link/POST] Link failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Link failed' }, - { status: 500 } - ) - } -} + try { + const document = await linkToJournalEntry( + supabase, + companyId!, + id, + body.journal_entry_id, + body.journal_entry_line_id, + ) + return NextResponse.json({ data: document }) + } catch (err) { + opLog.error('document link failed', err as Error, { + journalEntryId: body.journal_entry_id, + }) + const message = err instanceof Error ? err.message : '' + if (/journal entry not found/i.test(message)) { + return errorResponseFromCode('DOC_LINK_ENTRY_NOT_FOUND', opLog, { requestId }) + } + if (/already linked/i.test(message)) { + return errorResponseFromCode('DOC_LINK_ALREADY_LINKED', opLog, { requestId }) + } + return errorResponseFromCode('DOC_LINK_FAILED', opLog, { + requestId, + details: { reason: message || 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index abd1f1e8..a63e56cc 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -1,122 +1,113 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { DocumentUploadSource } from '@/types' ensureInitialized() /** - * POST /api/documents - * Upload a document to the WORM archive + * POST /api/documents — upload a document to the WORM archive. * - * Accepts multipart/form-data with: - * - file: The document file - * - upload_source (optional): 'camera' | 'file_upload' | 'email' | ... - * - journal_entry_id (optional): Link to a journal entry - * - journal_entry_line_id (optional): Link to a journal entry line + * multipart/form-data: + * file: the document file + * upload_source (optional): 'camera' | 'file_upload' | 'email' | … + * journal_entry_id (optional) + * journal_entry_line_id (optional) */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'document.upload', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { const formData = await request.formData() const file = formData.get('file') as File | null if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + return errorResponseFromCode('DOC_UPLOAD_NO_FILE', log, { requestId }) } const validationError = validateDocumentFile({ size: file.size, type: file.type }) if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }) + // The validator returns a Swedish string today. Bucket the failure into + // a size or type code based on its content. + const code = /storlek|stor|MB/i.test(validationError) + ? 'DOC_UPLOAD_TOO_LARGE' + : 'DOC_UPLOAD_UNSUPPORTED_TYPE' + return errorResponseFromCode(code, log, { + requestId, + details: { reason: validationError, sizeBytes: file.size, mimeType: file.type }, + }) } - const uploadSource = (formData.get('upload_source') as string) || 'file_upload' - const journalEntryId = formData.get('journal_entry_id') as string | null - const journalEntryLineId = formData.get('journal_entry_line_id') as string | null + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) - const buffer = await file.arrayBuffer() + try { + const uploadSource = (formData.get('upload_source') as string) || 'file_upload' + const journalEntryId = formData.get('journal_entry_id') as string | null + const journalEntryLineId = formData.get('journal_entry_line_id') as string | null - const document = await uploadDocument(supabase, user.id, companyId, { - name: file.name, - buffer, - type: file.type, - }, { - upload_source: uploadSource as import('@/types').DocumentUploadSource, - journal_entry_id: journalEntryId || undefined, - journal_entry_line_id: journalEntryLineId || undefined, - }) + const buffer = await file.arrayBuffer() - return NextResponse.json({ data: document }) - } catch (error) { - console.error('[documents/POST] Upload failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Upload failed' }, - { status: 500 } - ) - } -} + const document = await uploadDocument(supabase, user.id, companyId!, { + name: file.name, + buffer, + type: file.type, + }, { + upload_source: uploadSource as DocumentUploadSource, + journal_entry_id: journalEntryId || undefined, + journal_entry_line_id: journalEntryLineId || undefined, + }) + + return NextResponse.json({ data: document }) + } catch (err) { + opLog.error('document upload failed', err as Error) + return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) /** - * GET /api/documents - * List documents with optional filtering + * GET /api/documents — list documents. * * Query params: - * - journal_entry_id: Filter by journal entry - * - current_only: If 'true', only return current versions (default: true) - * - limit: Number of results (default: 50) - * - offset: Pagination offset (default: 0) + * journal_entry_id: filter by JE + * current_only: 'false' to include older versions (default true) + * limit, offset */ -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'document.list', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { searchParams } = new URL(request.url) + const journalEntryId = searchParams.get('journal_entry_id') + const currentOnly = searchParams.get('current_only') !== 'false' + const limit = parseInt(searchParams.get('limit') || '50') + const offset = parseInt(searchParams.get('offset') || '0') - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + let query = supabase + .from('document_attachments') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) - const companyId = await requireCompanyId(supabase, user.id) + if (journalEntryId) query = query.eq('journal_entry_id', journalEntryId) + if (currentOnly) query = query.eq('is_current_version', true) - const { searchParams } = new URL(request.url) - const journalEntryId = searchParams.get('journal_entry_id') - const currentOnly = searchParams.get('current_only') !== 'false' - const limit = parseInt(searchParams.get('limit') || '50') - const offset = parseInt(searchParams.get('offset') || '0') + const { data, error, count } = await query - let query = supabase - .from('document_attachments') - .select('*', { count: 'exact' }) - .eq('company_id', companyId) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) + if (error) { + log.error('document list failed', error) + return errorResponse(error, log, { requestId }) + } - if (journalEntryId) { - query = query.eq('journal_entry_id', journalEntryId) - } - - if (currentOnly) { - query = query.eq('is_current_version', true) - } - - const { data, error, count } = await query - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data, count }) -} + return NextResponse.json({ data, count }) + }, +) diff --git a/app/api/documents/verify/cron/route.ts b/app/api/documents/verify/cron/route.ts index 90dfb6f0..a84cc3a7 100644 --- a/app/api/documents/verify/cron/route.ts +++ b/app/api/documents/verify/cron/route.ts @@ -1,34 +1,27 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' /** - * GET /api/documents/verify/cron - * Batch integrity verification of WORM document archive - * - * Runs weekly (Sunday 03:00 UTC / 05:00 Swedish time). - * Processes up to 100 documents per run, prioritizing - * documents never checked or least recently checked. - * - * Uses service role for cross-user verification (RLS bypass). + * GET /api/documents/verify/cron — weekly Sunday 03:00 UTC. + * Spot-checks WORM archive integrity by recomputing SHA-256 for the next + * batch of documents and writing INTEGRITY_FAILURE rows to the audit log + * for any mismatches. */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - +export const GET = withCronContext('cron.documents_verify', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) - // Fetch up to 100 current-version documents, prioritizing unchecked/oldest const { data: documents, error: fetchError } = await supabase .from('document_attachments') .select('id, user_id, company_id, storage_path, sha256_hash, file_name') @@ -37,8 +30,8 @@ export async function GET(request: Request) { .limit(parseInt(process.env.DOCUMENT_VERIFY_BATCH_SIZE || '500', 10)) if (fetchError) { - console.error('[doc-verify-cron] Failed to fetch documents:', fetchError) - return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 }) + ctx.log.error('failed to fetch documents for verify', fetchError) + return errorResponse(fetchError, ctx.log, { requestId: ctx.requestId }) } if (!documents || documents.length === 0) { @@ -47,68 +40,63 @@ export async function GET(request: Request) { let verified = 0 let failures = 0 - let errors = 0 - for (const doc of documents) { - try { - // Download file from storage - const { data: fileData, error: downloadError } = await supabase.storage - .from('documents') - .download(doc.storage_path) + const summary = await ctx.forEach('document', documents, async (doc, itemCtx) => { + const { data: fileData, error: downloadError } = await supabase.storage + .from('documents') + .download(doc.storage_path) - if (downloadError || !fileData) { - console.error(`[doc-verify-cron] Download failed for ${doc.id}:`, downloadError) - errors++ - continue - } - - // Compute SHA-256 hash - const buffer = await fileData.arrayBuffer() - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') - - const isValid = computedHash === doc.sha256_hash - - // Update last_integrity_check_at - await supabase - .from('document_attachments') - .update({ last_integrity_check_at: new Date().toISOString() }) - .eq('id', doc.id) - - if (!isValid) { - // Log integrity failure to audit_log - await supabase.from('audit_log').insert({ - user_id: doc.user_id, - company_id: doc.company_id, - action: 'INTEGRITY_FAILURE', - table_name: 'document_attachments', - record_id: doc.id, - description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`, - old_state: { sha256_hash: doc.sha256_hash }, - new_state: { computed_hash: computedHash }, - }) - - console.error(`[doc-verify-cron] INTEGRITY FAILURE: document ${doc.id} (${doc.file_name})`) - failures++ - } else { - verified++ - } - } catch (error) { - console.error(`[doc-verify-cron] Error verifying document ${doc.id}:`, error) - errors++ - // Continue with other documents + if (downloadError || !fileData) { + throw new Error(downloadError?.message || 'download_failed') } - } - console.log( - `[doc-verify-cron] Processed ${documents.length}: ${verified} verified, ${failures} failures, ${errors} errors` - ) + const buffer = await fileData.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') - return NextResponse.json({ - processed: documents.length, + const isValid = computedHash === doc.sha256_hash + + await supabase + .from('document_attachments') + .update({ last_integrity_check_at: new Date().toISOString() }) + .eq('id', doc.id) + + if (!isValid) { + await supabase.from('audit_log').insert({ + user_id: doc.user_id, + company_id: doc.company_id, + action: 'INTEGRITY_FAILURE', + table_name: 'document_attachments', + record_id: doc.id, + description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`, + old_state: { sha256_hash: doc.sha256_hash }, + new_state: { computed_hash: computedHash }, + }) + + itemCtx.log.error('integrity failure', new Error('hash_mismatch'), { + documentId: doc.id, + fileName: doc.file_name, + storedHash: doc.sha256_hash, + computedHash, + }) + failures++ + } else { + verified++ + } + }) + + ctx.log.info('document verify summary', { + processed: summary.total, verified, failures, - errors, + downloadErrors: summary.failed, }) -} + + return NextResponse.json({ + processed: summary.total, + verified, + failures, + errors: summary.failed, + }) +}) diff --git a/app/api/events/cleanup/cron/route.ts b/app/api/events/cleanup/cron/route.ts index da028330..7be93af5 100644 --- a/app/api/events/cleanup/cron/route.ts +++ b/app/api/events/cleanup/cron/route.ts @@ -1,41 +1,30 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse } from '@/lib/errors/get-structured-error' /** - * GET /api/events/cleanup/cron - * Daily cron job to delete event_log rows older than 30 days. - * Runs at 02:00 UTC every day. + * GET /api/events/cleanup/cron — daily 02:00 UTC. + * Removes event_log rows older than 30 days. */ -export async function GET(request: Request) { - const authHeader = request.headers.get('authorization') - const cronSecret = process.env.CRON_SECRET +export const GET = withCronContext('cron.events_cleanup', async (_request, ctx) => { + const supabase = createServiceClient() - if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - 30) + + const { error, count } = await supabase + .from('event_log') + .delete({ count: 'exact' }) + .lt('created_at', cutoff.toISOString()) + + if (error) { + ctx.log.error('event log cleanup failed', error) + return errorResponse(error, ctx.log, { requestId: ctx.requestId }) } - try { - const supabase = await createServiceClient() + const deleted = count ?? 0 + ctx.log.info('event log cleanup summary', { deleted, cutoff: cutoff.toISOString() }) - const cutoff = new Date() - cutoff.setDate(cutoff.getDate() - 30) - - const { error, count } = await supabase - .from('event_log') - .delete({ count: 'exact' }) - .lt('created_at', cutoff.toISOString()) - - if (error) throw error - - const deleted = count ?? 0 - console.log(`Event log cleanup completed: ${deleted} events removed`) - - return NextResponse.json({ success: true, deleted }) - } catch (error) { - console.error('Error in event log cleanup cron:', error) - return NextResponse.json( - { error: 'Failed to clean up event log' }, - { status: 500 } - ) - } -} + return NextResponse.json({ success: true, deleted }) +}) diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts index d7c92014..81ce29a5 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { performSync, SCHEDULE_KEY, @@ -20,18 +21,15 @@ import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/type * `extension_data` carries its own `user_id` (the user who configured the * schedule), which we use as the "actor" when writing back the sync result. */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - +export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) @@ -45,11 +43,11 @@ export async function GET(request: Request) { .eq('key', SCHEDULE_KEY) if (error) { - console.error('[cloud-backup-cron] Failed to fetch schedules', { + ctx.log.error('failed to fetch schedules', error, { message: error.message, code: error.code, }) - return NextResponse.json({ error: 'Failed to fetch schedules' }, { status: 500 }) + return errorResponse(error, ctx.log, { requestId: ctx.requestId }) } if (!rows || rows.length === 0) { @@ -86,9 +84,10 @@ export async function GET(request: Request) { for (const row of candidates) { if (Date.now() - startTime > TIME_BUDGET_MS) { - console.log( - `[cloud-backup-cron] Time budget reached after ${results.length} companies; ${candidates.length - results.length} skipped until next run` - ) + ctx.log.info('time budget reached', { + processedSoFar: results.length, + skipped: candidates.length - results.length, + }) break } @@ -120,9 +119,8 @@ export async function GET(request: Request) { }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' - console.error('[cloud-backup-cron] Sync failed for company', { + ctx.log.error('cloud backup sync failed for company', err as Error, { companyId, - message, }) const updated: GoogleDriveSchedule = { @@ -133,8 +131,8 @@ export async function GET(request: Request) { } await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch( (persistErr) => { - console.error('[cloud-backup-cron] Failed to persist failure state', persistErr) - } + ctx.log.error('failed to persist failure state', persistErr as Error, { companyId }) + }, ) results.push({ companyId, status: 'error', error: message }) @@ -144,9 +142,11 @@ export async function GET(request: Request) { const successCount = results.filter((r) => r.status === 'success').length const errorCount = results.filter((r) => r.status === 'error').length - console.log( - `[cloud-backup-cron] Processed ${results.length} companies: ${successCount} succeeded, ${errorCount} failed` - ) + ctx.log.info('cloud backup cron summary', { + processed: results.length, + succeeded: successCount, + failed: errorCount, + }) return NextResponse.json({ checked: rows.length, @@ -156,4 +156,4 @@ export async function GET(request: Request) { errors: errorCount, results, }) -} +}) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 8f8d64df..9c988c01 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -10,7 +10,8 @@ import { generateConsentExpiryEmailSubject, } from '@/lib/email/consent-notification-templates' import { ensureInitialized } from '@/lib/init' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getBranding } from '@/lib/branding/service' import type { StoredAccount } from '@/extensions/general/enable-banking/types' @@ -25,18 +26,15 @@ ensureInitialized() * Prioritizes connections not synced for the longest time. * Deduplication via external_id makes repeated runs safe. */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - +export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) @@ -51,7 +49,7 @@ export async function GET(request: Request) { .select('id') if (stalePending?.length) { - console.log(`[bank-sync-cron] Cleaned up ${stalePending.length} stale pending connections`) + ctx.log.info('cleaned up stale pending connections', { count: stalePending.length }) } const { data: connections, error: connError } = await supabase @@ -62,12 +60,11 @@ export async function GET(request: Request) { .limit(50) if (connError) { - console.error('[bank-sync-cron] Failed to fetch bank connections', { + ctx.log.error('failed to fetch bank connections', connError, { message: connError.message, code: connError.code, - details: connError.details, }) - return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 }) + return errorResponse(connError, ctx.log, { requestId: ctx.requestId }) } if (!connections || connections.length === 0) { @@ -91,7 +88,7 @@ export async function GET(request: Request) { for (const connection of connections) { if (Date.now() - startTime > TIME_BUDGET_MS) { - console.log(`[bank-sync-cron] Time budget reached after ${results.length} connections`) + ctx.log.info('time budget reached', { processedSoFar: results.length }) break } @@ -137,7 +134,10 @@ export async function GET(request: Request) { const isFirstSync = !connection.last_synced_at const lookbackDays = isFirstSync ? 90 : 7 if (isFirstSync) { - console.log(`[bank-sync-cron] First sync for connection ${connection.id}, using ${lookbackDays}-day lookback`) + ctx.log.info('first sync for connection — using 90-day lookback', { + connectionId: connection.id, + lookbackDays, + }) } const fromDate = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000) .toISOString() @@ -212,16 +212,12 @@ export async function GET(request: Request) { }) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - console.error('[bank-sync-cron] Sync failed for connection', { + ctx.log.error('sync failed for connection', error as Error, { connectionId: connection.id, userId: connection.user_id, bankName: connection.bank_name, - sessionId: '[REDACTED]', consentExpires: connection.consent_expires, lastSyncedAt: connection.last_synced_at, - message, - stack: error instanceof Error ? error.stack : undefined, - name: error instanceof Error ? error.name : undefined, }) // Persist error status on sync failure @@ -247,7 +243,13 @@ export async function GET(request: Request) { const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length const totalFailed = results.filter(r => r.status === 'error').length - console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`) + ctx.log.info('bank sync summary', { + processed: results.length, + totalImported, + totalExpired, + totalExpiringSoon, + totalFailed, + }) return NextResponse.json({ processed: results.length, @@ -257,7 +259,7 @@ export async function GET(request: Request) { totalFailed, results, }) -} +}) /** * Send consent expiry notification email. @@ -316,7 +318,8 @@ async function sendConsentExpiryNotification( .update({ last_expiry_notification_at: new Date().toISOString() }) .eq('id', connection.id as string) } catch (error) { - // Notification failure must not break the cron job - console.error(`[bank-sync-cron] Failed to send consent expiry notification:`, error) + // Notification failure must not break the cron job — log only. + // eslint-disable-next-line no-console + console.error('[bank-sync-cron] failed to send consent expiry notification:', error) } } diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index c1e430dc..194b9218 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -4,8 +4,64 @@ import { ensureInitialized } from '@/lib/init' import { extensionRegistry } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { requireCompanyId } from '@/lib/company/context' +import { createLogger } from '@/lib/logger' import type { ApiRouteDefinition } from '@/lib/extensions/types' +const dispatcherLog = createLogger('extension-dispatcher') + +function generateRequestId(): string { + return `req_${crypto.randomUUID()}` +} + +/** + * Wrap a Response so support staff can find the inbound request in stdout + * logs by id: + * + * 1. set the `X-Request-Id` header if missing + * 2. for JSON error envelopes (`{ error: { code, ... } }`) that came back + * without a requestId, inject one into the body so the toast can show + * `Felreferens: req_…` + * + * Non-JSON responses (HTML for OAuth callbacks, file downloads) only get the + * header — body rewriting is reserved for the canonical envelope shape. + */ +async function decorateResponse(response: Response, requestId: string): Promise { + if (!response.headers.get('X-Request-Id')) { + response.headers.set('X-Request-Id', requestId) + } + + if (!response.ok) { + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + try { + const cloned = response.clone() + const body = await cloned.json() + if ( + body && + typeof body === 'object' && + body.error && + typeof body.error === 'object' && + typeof body.error.code === 'string' && + !body.error.requestId + ) { + const augmented = { + ...body, + error: { ...body.error, requestId }, + } + return new NextResponse(JSON.stringify(augmented), { + status: response.status, + headers: response.headers, + }) + } + } catch { + // Body wasn't valid JSON — leave it alone. + } + } + } + + return response +} + ensureInitialized() // Heavy extension routes (SIE import, migration) need up to 5 minutes @@ -75,20 +131,31 @@ async function handleRequest( request: Request, { params }: { params: Promise<{ path: string[] }> } ): Promise { + const requestId = generateRequestId() + const start = Date.now() const segments = await params if (!segments.path || segments.path.length < 1) { - return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 }) + return decorateResponse( + NextResponse.json({ error: 'Invalid extension route' }, { status: 400 }), + requestId, + ) } const [extensionId, ...rest] = segments.path const routePath = '/' + rest.join('/') const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + const log = dispatcherLog.child({ requestId, extensionId, routePath, method }) + // Look up extension const extension = extensionRegistry.get(extensionId) if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) { - return NextResponse.json({ error: 'Extension not found' }, { status: 404 }) + log.warn('extension not found') + return decorateResponse( + NextResponse.json({ error: 'Extension not found' }, { status: 404 }), + requestId, + ) } // Per-extension feature flags. Lets us toggle a single integration off @@ -97,9 +164,12 @@ async function handleRequest( // render an "extension disabled" empty state. const flag = EXTENSION_FEATURE_FLAGS[extensionId] if (flag && process.env[flag.envVar] !== 'true') { - return NextResponse.json( - { error: flag.disabledMessage, code: 'EXTENSION_DISABLED' }, - { status: 503 }, + return decorateResponse( + NextResponse.json( + { error: flag.disabledMessage, code: 'EXTENSION_DISABLED' }, + { status: 503 }, + ), + requestId, ) } @@ -119,7 +189,10 @@ async function handleRequest( } if (!matchedRoute) { - return NextResponse.json({ error: 'Route not found' }, { status: 404 }) + return decorateResponse( + NextResponse.json({ error: 'Route not found' }, { status: 404 }), + requestId, + ) } // Config sanity check: these flags are orthogonal and the combination is @@ -129,12 +202,11 @@ async function handleRequest( // the auth requirement would be silently dropped (skipAuth fires first // below). Fail loudly instead of masking the mistake. if (matchedRoute.skipAuth && matchedRoute.skipCompanyContext) { - console.error('[extension-dispatcher] route misconfigured: skipAuth + skipCompanyContext are mutually exclusive', { - extensionId, - routePath, - method, - }) - return NextResponse.json({ error: 'Route misconfigured' }, { status: 500 }) + log.error('route misconfigured: skipAuth + skipCompanyContext are mutually exclusive', undefined) + return decorateResponse( + NextResponse.json({ error: 'Route misconfigured' }, { status: 500 }), + requestId, + ) } // For skipAuth routes (e.g. OAuth callbacks from external providers), @@ -155,7 +227,9 @@ async function handleRequest( duplex: 'half', }) } - return matchedRoute.handler(handlerRequest) + const response = await matchedRoute.handler(handlerRequest) + log.info('extension call completed', { durationMs: Date.now() - start, status: response.status }) + return decorateResponse(response, requestId) } // Auth check @@ -163,7 +237,10 @@ async function handleRequest( const { data: { user } } = await supabase.auth.getUser() if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return decorateResponse( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + requestId, + ) } // If path params were extracted, create a new Request with them as search params @@ -188,14 +265,27 @@ async function handleRequest( // /lookup during onboarding, for example) opt out of company resolution. // Dispatch without a context — handlers that opt in must not rely on ctx. if (matchedRoute.skipCompanyContext) { - return matchedRoute.handler(handlerRequest) + const response = await matchedRoute.handler(handlerRequest) + log.info('extension call completed', { + durationMs: Date.now() - start, + status: response.status, + userId: user.id, + }) + return decorateResponse(response, requestId) } const companyId = await requireCompanyId(supabase, user.id) // Build context and dispatch - const ctx = createExtensionContext(supabase, user.id, companyId, extensionId) - return matchedRoute.handler(handlerRequest, ctx) + const ctx = createExtensionContext(supabase, user.id, companyId, extensionId, requestId) + const response = await matchedRoute.handler(handlerRequest, ctx) + log.info('extension call completed', { + durationMs: Date.now() - start, + status: response.status, + userId: user.id, + companyId, + }) + return decorateResponse(response, requestId) } export const GET = handleRequest diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index 60dabf0e..f6bc271c 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -6,41 +6,30 @@ import { sendInvoiceNotifications, sendMissingUnderlagNotifications, } from '@/extensions/general/push-notifications/notification-scheduler' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' /** - * GET /api/extensions/push-notifications/cron - * Daily cron job to send push notifications - * Runs at 09:00 every day - * - * Vercel Cron: "0 9 * * *" + * GET /api/extensions/push-notifications/cron — daily 09:00 UTC. + * Sends due tax, invoice and missing-underlag push notifications. */ -export async function GET(request: Request) { - // Ensure extensions are loaded so event handlers are registered +export const GET = withCronContext('cron.push_notifications', async (_request, ctx) => { + // Ensure extensions are loaded so event handlers are registered. loadExtensions() - // Verify cron secret for security - const authHeader = request.headers.get('authorization') - const cronSecret = process.env.CRON_SECRET - - if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Create a service role client for accessing all user data const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) try { - // Send all notification types in parallel const [taxResult, invoiceResult, underlagResult] = await Promise.all([ sendTaxDeadlineNotifications(supabase), sendInvoiceNotifications(supabase), @@ -50,18 +39,13 @@ export async function GET(request: Request) { const totalSent = taxResult.sent + invoiceResult.sent + underlagResult.sent const totalSkipped = taxResult.skipped + invoiceResult.skipped + underlagResult.skipped - console.log( - `Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped` - ) - console.log( - ` Tax: ${taxResult.sent} sent, ${taxResult.skipped} skipped` - ) - console.log( - ` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped` - ) - console.log( - ` Missing underlag: ${underlagResult.sent} sent, ${underlagResult.skipped} skipped` - ) + ctx.log.info('push notification cron summary', { + totalSent, + totalSkipped, + taxSent: taxResult.sent, + invoiceSent: invoiceResult.sent, + underlagSent: underlagResult.sent, + }) return NextResponse.json({ success: true, @@ -73,11 +57,8 @@ export async function GET(request: Request) { missingUnderlag: underlagResult, }, }) - } catch (error) { - console.error('Error in push notification cron:', error) - return NextResponse.json( - { error: 'Failed to send push notifications' }, - { status: 500 } - ) + } catch (err) { + ctx.log.error('push notification cron failed', err as Error) + return errorResponse(err, ctx.log, { requestId: ctx.requestId }) } -} +}) diff --git a/app/api/idempotency/cleanup/cron/route.ts b/app/api/idempotency/cleanup/cron/route.ts index 32f12af9..b133f627 100644 --- a/app/api/idempotency/cleanup/cron/route.ts +++ b/app/api/idempotency/cleanup/cron/route.ts @@ -1,31 +1,21 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { cleanupExpiredIdempotencyKeys } from '@/lib/api/idempotency' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse } from '@/lib/errors/get-structured-error' /** - * GET /api/idempotency/cleanup/cron - * - * Sweeps idempotency_keys rows past their 24h TTL. Cron runs hourly so the - * working set stays small even under heavy agent retry traffic. + * GET /api/idempotency/cleanup/cron — hourly. + * Sweeps idempotency_keys past their 24h TTL. */ -export async function GET(request: Request) { - const authHeader = request.headers.get('authorization') - const cronSecret = process.env.CRON_SECRET - - if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - +export const GET = withCronContext('cron.idempotency_cleanup', async (_request, ctx) => { try { const supabase = await createServiceClient() const deleted = await cleanupExpiredIdempotencyKeys(supabase) - console.log(`Idempotency keys cleanup completed: ${deleted} rows removed`) + ctx.log.info('idempotency cleanup summary', { deleted }) return NextResponse.json({ success: true, deleted }) - } catch (error) { - console.error('Error in idempotency cleanup cron:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to clean up idempotency keys' }, - { status: 500 } - ) + } catch (err) { + ctx.log.error('idempotency cleanup failed', err as Error) + return errorResponse(err, ctx.log, { requestId: ctx.requestId }) } -} +}) diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index 4df7cfdc..1914b598 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -1,4 +1,3 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' @@ -6,6 +5,8 @@ import { ingestTransactions, type RawTransaction } from '@/lib/transactions/inge import { generateExternalId } from '@/lib/import/bank-file/parser' import type { IngestOptions } from '@/types' import { getCompanyRole } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types' import type { Transaction } from '@/types' @@ -24,119 +25,127 @@ interface ExecuteRequest { /** * POST /api/import/bank-file/execute * - * Executes the import of confirmed bank transactions. - * Records import in bank_file_imports, calls ingestTransactions(), - * emits transaction.synced event. + * Executes the import of confirmed bank transactions. Records the import in + * `bank_file_imports`, calls `ingestTransactions`, and emits `transaction.synced`. */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'bank_file.execute', + async (request, ctx) => { + const { user, supabase, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const roleCheck = await getCompanyRole(supabase, user.id) - if (!roleCheck.ok) return roleCheck.response - const { role, companyId } = roleCheck - - const body: ExecuteRequest = await request.json() - const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true, settlement_account } = body - - if (!transactions || transactions.length === 0) { - return NextResponse.json({ error: 'No transactions to import' }, { status: 400 }) - } - - try { - // Create import record - const { data: importRecord, error: importError } = await supabase - .from('bank_file_imports') - .upsert({ - user_id: user.id, - company_id: companyId, - filename, - file_hash, - file_format: format, - transaction_count: transactions.length, - status: 'processing', - date_from: transactions.map(t => t.date).sort()[0] || null, - date_to: transactions.map(t => t.date).sort().reverse()[0] || null, - }, { - onConflict: 'user_id,file_hash', - }) - .select() - .single() - - if (importError) { - console.error('Failed to create import record:', importError) - return NextResponse.json({ error: 'Failed to create import record' }, { status: 500 }) - } - - // Convert parsed transactions to RawTransaction format - const rawTransactions: RawTransaction[] = transactions.map((tx, index) => ({ - date: tx.date, - description: tx.description, - amount: tx.amount, - currency: tx.currency || 'SEK', - external_id: generateExternalId(tx, format, index), - reference: tx.reference || null, - import_source: format === 'camt053' ? 'camt053' : `csv_${format}`, - })) - - // Run ingestion pipeline — viewers get rawInsertOnly (no categorization, no matching) - const ingestOptions: IngestOptions = {} - if (settlement_account) ingestOptions.settlementAccount = settlement_account - if (role === 'viewer') ingestOptions.rawInsertOnly = true - const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions) - - // Update import record with results - await supabase - .from('bank_file_imports') - .update({ - imported_count: ingestResult.imported, - duplicate_count: ingestResult.duplicates, - matched_count: ingestResult.auto_matched_invoices, - status: ingestResult.errors > 0 && ingestResult.imported === 0 ? 'failed' : 'completed', - error_message: ingestResult.errors > 0 - ? `${ingestResult.errors} transactions failed to import` - : null, - }) - .eq('id', importRecord.id) - - // Emit event with newly imported transactions - if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) { - try { - const { data: importedTransactions } = await supabase - .from('transactions') - .select('*') - .in('id', ingestResult.transaction_ids) - - if (importedTransactions && importedTransactions.length > 0) { - await eventBus.emit({ - type: 'transaction.synced', - payload: { - transactions: importedTransactions as Transaction[], - userId: user.id, - companyId, - }, - }) - } - } catch { - // Non-critical event emission + // We still call getCompanyRole because viewers are allowed through with + // rawInsertOnly behavior — `requireWrite: true` would block them. + const roleCheck = await getCompanyRole(supabase, user.id) + if (!roleCheck.ok) { + // Inject the request id for traceability and pass through. + if (!roleCheck.response.headers.get('X-Request-Id')) { + roleCheck.response.headers.set('X-Request-Id', requestId) } + return roleCheck.response + } + const { role, companyId } = roleCheck + + const body: ExecuteRequest = await request.json() + const { + transactions, format, filename, file_hash, + skip_duplicates: _skip_duplicates = true, + auto_categorize: _auto_categorize = true, + settlement_account, + } = body + + if (!transactions || transactions.length === 0) { + return errorResponseFromCode('BANK_FILE_NO_TRANSACTIONS', log, { requestId }) } - return NextResponse.json({ - data: { - import_id: importRecord.id, - ...ingestResult, - }, - }) - } catch (error) { - console.error('Bank file execute error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Import failed' }, - { status: 500 } - ) - } -} + const opLog = log.child({ filename, fileHash: file_hash, txCount: transactions.length }) + + try { + const { data: importRecord, error: importError } = await supabase + .from('bank_file_imports') + .upsert({ + user_id: user.id, + company_id: companyId, + filename, + file_hash, + file_format: format, + transaction_count: transactions.length, + status: 'processing', + date_from: transactions.map((t) => t.date).sort()[0] || null, + date_to: transactions.map((t) => t.date).sort().reverse()[0] || null, + }, { onConflict: 'user_id,file_hash' }) + .select() + .single() + + if (importError) { + opLog.error('failed to create bank_file_imports record', importError) + return errorResponseFromCode('BANK_FILE_IMPORT_RECORD_FAILED', opLog, { + requestId, + details: { reason: importError.message }, + }) + } + + const rawTransactions: RawTransaction[] = transactions.map((tx, index) => ({ + date: tx.date, + description: tx.description, + amount: tx.amount, + currency: tx.currency || 'SEK', + external_id: generateExternalId(tx, format, index), + reference: tx.reference || null, + import_source: format === 'camt053' ? 'camt053' : `csv_${format}`, + })) + + const ingestOptions: IngestOptions = {} + if (settlement_account) ingestOptions.settlementAccount = settlement_account + if (role === 'viewer') ingestOptions.rawInsertOnly = true + const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions) + + await supabase + .from('bank_file_imports') + .update({ + imported_count: ingestResult.imported, + duplicate_count: ingestResult.duplicates, + matched_count: ingestResult.auto_matched_invoices, + status: ingestResult.errors > 0 && ingestResult.imported === 0 ? 'failed' : 'completed', + error_message: ingestResult.errors > 0 + ? `${ingestResult.errors} transactions failed to import` + : null, + }) + .eq('id', importRecord.id) + + if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) { + try { + const { data: importedTransactions } = await supabase + .from('transactions') + .select('*') + .in('id', ingestResult.transaction_ids) + + if (importedTransactions && importedTransactions.length > 0) { + await eventBus.emit({ + type: 'transaction.synced', + payload: { + transactions: importedTransactions as Transaction[], + userId: user.id, + companyId, + }, + }) + } + } catch (err) { + opLog.warn('transaction.synced event emission failed', err as Error) + } + } + + return NextResponse.json({ + data: { + import_id: importRecord.id, + ...ingestResult, + }, + }) + } catch (err) { + opLog.error('bank file execute failed', err as Error) + return errorResponseFromCode('BANK_FILE_EXECUTE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts index 22175ca5..e32161ef 100644 --- a/app/api/import/bank-file/parse/route.ts +++ b/app/api/import/bank-file/parse/route.ts @@ -1,101 +1,98 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser' import { decodeFileContent } from '@/lib/import/bank-file/encoding' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { BankFileFormatId } from '@/lib/import/bank-file/types' /** * POST /api/import/bank-file/parse * - * Accepts a bank file (CSV/XML) via FormData, auto-detects format, - * returns parsed transactions preview with duplicate detection. + * Accepts a bank file (CSV/XML) via FormData, auto-detects format, and returns + * a parsed transactions preview with duplicate detection. */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'bank_file.parse', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const formData = await request.formData() + const file = formData.get('file') as File | null + const formatOverride = formData.get('format') as BankFileFormatId | null - const companyId = await requireCompanyId(supabase, user.id) - - const formData = await request.formData() - const file = formData.get('file') as File | null - const formatOverride = formData.get('format') as BankFileFormatId | null - - if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - } - - // Validate file size (10MB max) - if (file.size > 10 * 1024 * 1024) { - return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 }) - } - - try { - // Read and decode file content - const arrayBuffer = await file.arrayBuffer() - const content = decodeFileContent(arrayBuffer) - const fileHash = generateFileHash(content) - - // Check if this exact file has been imported before - const { data: existingImport } = await supabase - .from('bank_file_imports') - .select('id, status, imported_count, created_at') - .eq('company_id', companyId) - .eq('file_hash', fileHash) - .single() - - if (existingImport && existingImport.status === 'completed') { - return NextResponse.json({ - error: 'duplicate', - message: `Den här filen har redan importerats (${existingImport.imported_count} transaktioner, ${new Date(existingImport.created_at).toLocaleDateString('sv-SE')})`, - }, { status: 409 }) + if (!file) { + return errorResponseFromCode('BANK_FILE_NO_FILE', log, { requestId }) } - // Auto-detect or use specified format - const detectedFormat = formatOverride - ? null - : detectFileFormat(content, file.name) + if (file.size > 10 * 1024 * 1024) { + return errorResponseFromCode('BANK_FILE_TOO_LARGE', log, { + requestId, + details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) }, + }) + } - // Parse the file - const parseResult = parseBankFile(content, file.name, formatOverride || undefined) + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) - // Check for existing transactions (duplicate detection for preview) - let existingCount = 0 - if (parseResult.transactions.length > 0) { - // Sample check: look for transactions with matching dates and amounts - const { count } = await supabase - .from('transactions') - .select('*', { count: 'exact', head: true }) + try { + const arrayBuffer = await file.arrayBuffer() + const content = decodeFileContent(arrayBuffer) + const fileHash = generateFileHash(content) + + const { data: existingImport } = await supabase + .from('bank_file_imports') + .select('id, status, imported_count, created_at') .eq('company_id', companyId) - .gte('date', parseResult.date_from || '1970-01-01') - .lte('date', parseResult.date_to || '2099-12-31') + .eq('file_hash', fileHash) + .single() - existingCount = count || 0 + if (existingImport && existingImport.status === 'completed') { + return errorResponseFromCode('BANK_FILE_DUPLICATE', opLog, { + requestId, + details: { + importId: existingImport.id, + importedCount: existingImport.imported_count, + importedAt: existingImport.created_at, + }, + }) + } + + const detectedFormat = formatOverride + ? null + : detectFileFormat(content, file.name) + + const parseResult = parseBankFile(content, file.name, formatOverride || undefined) + + let existingCount = 0 + if (parseResult.transactions.length > 0) { + const { count } = await supabase + .from('transactions') + .select('*', { count: 'exact', head: true }) + .eq('company_id', companyId) + .gte('date', parseResult.date_from || '1970-01-01') + .lte('date', parseResult.date_to || '2099-12-31') + + existingCount = count || 0 + } + + return NextResponse.json({ + data: { + parse_result: parseResult, + detected_format: detectedFormat?.id || formatOverride || null, + detected_format_name: detectedFormat?.name || parseResult.format_name, + file_hash: fileHash, + filename: file.name, + existing_transaction_count: existingCount, + headers: parseResult.format === 'generic_csv' + ? content.split('\n')[0]?.split(',').map((h) => h.trim()) || [] + : null, + }, + }) + } catch (err) { + opLog.error('bank file parse failed', err as Error) + return errorResponseFromCode('BANK_FILE_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) } - - return NextResponse.json({ - data: { - parse_result: parseResult, - detected_format: detectedFormat?.id || formatOverride || null, - detected_format_name: detectedFormat?.name || parseResult.format_name, - file_hash: fileHash, - filename: file.name, - existing_transaction_count: existingCount, - // Return first row headers for generic CSV column mapping - headers: parseResult.format === 'generic_csv' - ? content.split('\n')[0]?.split(',').map(h => h.trim()) || [] - : null, - }, - }) - } catch (error) { - console.error('Bank file parse error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to parse file' }, - { status: 500 } - ) - } -} + }, +) diff --git a/app/api/import/opening-balance/__tests__/execute.test.ts b/app/api/import/opening-balance/__tests__/execute.test.ts index 31fcbf0d..4c558a61 100644 --- a/app/api/import/opening-balance/__tests__/execute.test.ts +++ b/app/api/import/opening-balance/__tests__/execute.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) const mockCreateJournalEntry = vi.fn() @@ -101,7 +102,7 @@ describe('POST /api/import/opening-balance/execute', () => { const { status, body } = await parseJsonResponse(res) expect(status).toBe(404) - expect(body.error).toContain('hittades inte') + expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_NOT_FOUND') }) it('returns 409 if period already has opening balances', async () => { @@ -127,7 +128,10 @@ describe('POST /api/import/opening-balance/execute', () => { const { status, body } = await parseJsonResponse(res) expect(status).toBe(409) - expect(body.existing_entry_id).toBe('entry-existing') + expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_ALREADY_HAS_BALANCES') + expect( + (body.error as unknown as { details: { existingEntryId: string } }).details.existingEntryId, + ).toBe('entry-existing') }) it('returns 400 for unbalanced lines', async () => { @@ -152,7 +156,7 @@ describe('POST /api/import/opening-balance/execute', () => { const { status, body } = await parseJsonResponse(res) expect(status).toBe(400) - expect(body.error).toContain('balanserar inte') + expect((body.error as unknown as { code: string }).code).toBe('OB_UNBALANCED') }) it('returns 400 for P&L accounts', async () => { @@ -177,7 +181,7 @@ describe('POST /api/import/opening-balance/execute', () => { const { status, body } = await parseJsonResponse(res) expect(status).toBe(400) - expect(body.error).toContain('Resultatkonton') + expect((body.error as unknown as { code: string }).code).toBe('OB_PNL_ACCOUNT') }) it('creates journal entry on success', async () => { diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts index 5f7b07bb..d53492fc 100644 --- a/app/api/import/opening-balance/execute/route.ts +++ b/app/api/import/opening-balance/execute/route.ts @@ -1,14 +1,13 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' 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 { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { CreateJournalEntryLineInput } from '@/types' ensureInitialized() @@ -16,238 +15,214 @@ ensureInitialized() /** * POST /api/import/opening-balance/execute * - * Creates an opening balance journal entry from user-confirmed lines. - * Auto-activates BAS accounts not yet in the company's chart. + * Creates an opening balance journal entry from user-confirmed lines and + * auto-activates BAS accounts not yet in the company's chart. */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'opening_balance.execute', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await validateBody(request, OpeningBalanceExecuteSchema, { + log, + operation: 'opening_balance.execute', + }) + if (!result.success) return result.response - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { fiscal_period_id, lines } = result.data + const opLog = log.child({ fiscalPeriodId: fiscal_period_id }) - const result = await validateBody(request, OpeningBalanceExecuteSchema) - if (!result.success) return result.response - - const { fiscal_period_id, lines } = result.data - - let companyId: string - try { - companyId = await requireCompanyId(supabase, user.id) - } catch { - return NextResponse.json({ error: 'Inget aktivt företag' }, { status: 400 }) - } - - try { - // 1. Verify fiscal period exists, belongs to company, and is not closed/locked - const { data: period, error: periodError } = await supabase - .from('fiscal_periods') - .select('*') - .eq('id', fiscal_period_id) - .eq('company_id', companyId) - .single() - - if (periodError || !period) { - return NextResponse.json( - { error: 'Räkenskapsperioden hittades inte' }, - { status: 404 }, - ) - } - - if (period.is_closed) { - return NextResponse.json( - { error: 'Räkenskapsperioden är stängd' }, - { status: 400 }, - ) - } - - if (period.locked_at) { - return NextResponse.json( - { error: 'Räkenskapsperioden är låst' }, - { status: 400 }, - ) - } - - // 2. Check if period already has opening balances - if (period.opening_balances_set) { - return NextResponse.json( - { - error: 'Räkenskapsperioden har redan ingående balanser', - existing_entry_id: period.opening_balance_entry_id, - }, - { status: 409 }, - ) - } - - // 3. Filter out zero-amount lines and validate no P&L accounts - const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0) - - if (validLines.length < 2) { - return NextResponse.json( - { error: 'Minst två rader med belopp krävs' }, - { status: 400 }, - ) - } - - // Reject class 3-8 accounts - const pnlAccounts = validLines - .map((l) => l.account_number) - .filter((num) => { - const cls = parseInt(num.charAt(0), 10) - return cls >= 3 && cls <= 8 - }) - - if (pnlAccounts.length > 0) { - return NextResponse.json( - { - error: `Resultatkonton (klass 3-8) kan inte användas i ingående balanser: ${pnlAccounts.slice(0, 5).join(', ')}`, - }, - { status: 400 }, - ) - } - - // 4. Verify balance - let totalDebit = 0 - let totalCredit = 0 - for (const line of validLines) { - totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100 - totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100 - } - - const diff = Math.round((totalDebit - totalCredit) * 100) / 100 - if (Math.abs(diff) >= 0.01) { - return NextResponse.json( - { error: `Debet och kredit balanserar inte — differens: ${diff.toFixed(2)} SEK` }, - { status: 400 }, - ) - } - - // 5. Auto-activate BAS accounts not in company's chart - const accountNumbers = [...new Set(validLines.map((l) => l.account_number))] - - const existingAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('account_number') + try { + // 1. Verify fiscal period belongs to the company and is open. + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', fiscal_period_id) .eq('company_id', companyId) - .range(from, to), - ) + .single() - const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) - const accountsToActivate = accountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) + if (periodError || !period) { + return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId }) + } + + if (period.is_closed) { + return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId }) + } + + if (period.locked_at) { + return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId }) + } + + if (period.opening_balances_set) { + return errorResponseFromCode('OB_PERIOD_ALREADY_HAS_BALANCES', opLog, { + requestId, + details: { existingEntryId: period.opening_balance_entry_id }, + }) + } + + // 2. Filter zero-amount lines and reject P&L accounts. + const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0) + + if (validLines.length < 2) { + return errorResponseFromCode('OB_TOO_FEW_LINES', opLog, { requestId }) + } + + const pnlAccounts = validLines + .map((l) => l.account_number) + .filter((num) => { + const cls = parseInt(num.charAt(0), 10) + return cls >= 3 && cls <= 8 + }) + + if (pnlAccounts.length > 0) { + return errorResponseFromCode('OB_PNL_ACCOUNT', opLog, { + requestId, + details: { accounts: pnlAccounts.slice(0, 5) }, + }) + } + + // 3. Verify balance. + let totalDebit = 0 + let totalCredit = 0 + for (const line of validLines) { + totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100 + totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100 + } + + const diff = Math.round((totalDebit - totalCredit) * 100) / 100 + if (Math.abs(diff) >= 0.01) { + return errorResponseFromCode('OB_UNBALANCED', opLog, { + requestId, + details: { totalDebit, totalCredit, diff }, + }) + } + + // 4. Auto-activate BAS accounts not in the company's chart. + const accountNumbers = [...new Set(validLines.map((l) => l.account_number))] + + const existingAccounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .range(from, to), + ) + + const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) + const accountsToActivate = accountNumbers + .filter((num) => !existingNumbers.has(num)) + .map((num) => { + const ref = getBASReference(num) + + if (ref) { + 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), + } + } + + const accountClass = parseInt(num.charAt(0), 10) + const accountGroup = num.substring(0, 2) + const accountType = + accountClass === 1 ? 'asset' + : accountClass === 2 ? 'liability' + : accountClass === 3 ? 'revenue' + : 'expense' + const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' - if (ref) { 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, + account_number: num, + account_name: `Konto ${num}`, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, 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), + description: `Konto ${num}`, + sru_code: null, + sort_order: parseInt(num), } + }) + + if (accountsToActivate.length > 0) { + const { error: activateError } = await supabase + .from('chart_of_accounts') + .insert(accountsToActivate) + + if (activateError) { + opLog.error('opening balance account activation failed', activateError) + return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, { + requestId, + details: { reason: activateError.message }, + }) } - - // Derive metadata from account number - const accountClass = parseInt(num.charAt(0), 10) - const accountGroup = num.substring(0, 2) - const accountType = - accountClass === 1 ? 'asset' - : accountClass === 2 ? 'liability' - : accountClass === 3 ? 'revenue' - : 'expense' - const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' - - return { - user_id: user.id, - company_id: companyId, - account_number: num, - account_name: `Konto ${num}`, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: `Konto ${num}`, - sru_code: null, - sort_order: parseInt(num), - } - }) - - if (accountsToActivate.length > 0) { - const { error: activateError } = await supabase - .from('chart_of_accounts') - .insert(accountsToActivate) - - if (activateError) { - console.error('Failed to activate accounts:', activateError) - return NextResponse.json( - { error: 'Kunde inte aktivera konton i kontoplanen' }, - { status: 500 }, - ) } - } - // 6. Create journal entry via engine - const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({ - account_number: line.account_number, - debit_amount: line.debit_amount, - credit_amount: line.credit_amount, - line_description: `IB ${line.account_number}`, - })) + // 5. Create the opening balance journal entry. + const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + line_description: `IB ${line.account_number}`, + })) - const entry = await createJournalEntry(supabase, companyId, user.id, { - fiscal_period_id, - entry_date: period.period_start, - description: 'Ingående balanser (Excel-import)', - source_type: 'opening_balance', - voucher_series: 'A', - lines: entryLines, - }) - - // 7. Update fiscal period - await supabase - .from('fiscal_periods') - .update({ - opening_balance_entry_id: entry.id, - opening_balances_set: true, - }) - .eq('id', fiscal_period_id) - .eq('company_id', companyId) - - return NextResponse.json({ - data: { - success: true, - journal_entry_id: entry.id, + const entry = await createJournalEntry(supabase, companyId!, user.id, { fiscal_period_id, - lines_created: entryLines.length, - total_debit: totalDebit, - total_credit: totalCredit, - }, - }) - } catch (error) { - const typed = bookkeepingErrorResponse(error) - if (typed) return typed - console.error('Opening balance execute error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Importen misslyckades' }, - { status: 500 }, - ) - } -} + entry_date: period.period_start, + description: 'Ingående balanser (Excel-import)', + source_type: 'opening_balance', + voucher_series: 'A', + lines: entryLines, + }) + + // 6. Mark the fiscal period. + await supabase + .from('fiscal_periods') + .update({ + opening_balance_entry_id: entry.id, + opening_balances_set: true, + }) + .eq('id', fiscal_period_id) + .eq('company_id', companyId) + + return NextResponse.json({ + data: { + success: true, + journal_entry_id: entry.id, + fiscal_period_id, + lines_created: entryLines.length, + total_debit: totalDebit, + total_credit: totalCredit, + }, + }) + } catch (err) { + // Bookkeeping errors flow through the standard envelope; everything else + // becomes OB_EXECUTE_FAILED so the user gets a Swedish toast. + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('opening balance execute failed', err as Error) + return errorResponseFromCode('OB_EXECUTE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/opening-balance/parse/route.ts b/app/api/import/opening-balance/parse/route.ts index 6cd3fe8f..a53c28ab 100644 --- a/app/api/import/opening-balance/parse/route.ts +++ b/app/api/import/opening-balance/parse/route.ts @@ -1,74 +1,67 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { parseOpeningBalanceFile } from '@/lib/import/opening-balance/parser' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { DetectedColumns } from '@/lib/import/opening-balance/types' const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods'] -const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB /** * POST /api/import/opening-balance/parse * - * Accepts an Excel/CSV file via FormData, auto-detects columns, - * returns parsed opening balance rows with BAS matching. + * Accepts an Excel/CSV file via FormData, auto-detects columns, and returns + * parsed opening balance rows with BAS matching. */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'opening_balance.parse', + async (request, ctx) => { + const { log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const formData = await request.formData() + const file = formData.get('file') as File | null + const columnOverridesRaw = formData.get('column_overrides') as string | null - const formData = await request.formData() - const file = formData.get('file') as File | null - const columnOverridesRaw = formData.get('column_overrides') as string | null + if (!file) { + return errorResponseFromCode('OB_NO_FILE', log, { requestId }) + } - if (!file) { - return NextResponse.json({ error: 'Ingen fil bifogad' }, { status: 400 }) - } + if (file.size > MAX_FILE_SIZE) { + return errorResponseFromCode('OB_FILE_TOO_LARGE', log, { + requestId, + details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) }, + }) + } - // Validate file size - if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: 'Filen är för stor (max 10MB)' }, - { status: 400 }, - ) - } + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return errorResponseFromCode('OB_INVALID_FORMAT', log, { + requestId, + details: { extension: ext, allowed: ALLOWED_EXTENSIONS }, + }) + } - // Validate file extension - const ext = '.' + file.name.split('.').pop()?.toLowerCase() - if (!ALLOWED_EXTENSIONS.includes(ext)) { - return NextResponse.json( - { error: `Filformatet stöds inte. Tillåtna format: ${ALLOWED_EXTENSIONS.join(', ')}` }, - { status: 400 }, - ) - } + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) - try { - const buffer = await file.arrayBuffer() - - // Parse optional column overrides let columnOverrides: DetectedColumns | undefined if (columnOverridesRaw) { try { columnOverrides = JSON.parse(columnOverridesRaw) } catch { - return NextResponse.json( - { error: 'Ogiltigt kolumnmappningsformat' }, - { status: 400 }, - ) + return errorResponseFromCode('OB_INVALID_COLUMN_OVERRIDES', opLog, { requestId }) } } - const result = parseOpeningBalanceFile(buffer, file.name, columnOverrides) - - return NextResponse.json({ data: result }) - } catch (error) { - console.error('Opening balance parse error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Kunde inte tolka filen' }, - { status: 500 }, - ) - } -} + try { + const buffer = await file.arrayBuffer() + const result = parseOpeningBalanceFile(buffer, file.name, columnOverrides) + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('opening balance parse failed', err as Error) + return errorResponseFromCode('OB_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/import/sie/[id]/replace/route.ts b/app/api/import/sie/[id]/replace/route.ts index 74e93e87..fa7e0ad2 100644 --- a/app/api/import/sie/[id]/replace/route.ts +++ b/app/api/import/sie/[id]/replace/route.ts @@ -1,42 +1,31 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { replaceSIEImport } from '@/lib/import/sie-import' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' /** * POST /api/import/sie/[id]/replace - * Replace a completed SIE import by cancelling its entries, allowing - * the user to re-import corrected data for the same fiscal period. + * + * Replace a completed SIE import by cancelling its entries, allowing the user + * to re-import corrected data for the same fiscal period. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext( + 'sie_import.replace', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ sieImportId: id }) - const { - data: { user }, - } = await supabase.auth.getUser() + const result = await replaceSIEImport(supabase, companyId!, id) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!result.success) { + return errorResponseFromCode('SIE_REPLACE_FAILED', opLog, { + requestId, + details: { reason: result.error }, + }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const result = await replaceSIEImport(supabase, companyId, id) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ - success: true, - cancelledEntries: result.cancelledEntries, - }) -} + return NextResponse.json({ success: true, cancelledEntries: result.cancelledEntries }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 12c6852e..7a062745 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -1,246 +1,218 @@ -import { createClient } from '@/lib/supabase/server' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser' import { suggestMappings } from '@/lib/import/account-mapper' import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types' // SIE imports with many vouchers need extended execution time export const maxDuration = 300 -/** - * POST /api/import/sie/execute - * Execute the SIE import - */ -export async function POST(request: Request) { - const supabase = await createClient() +/** POST /api/import/sie/execute — execute the SIE import. */ +export const POST = withRouteContext( + 'sie_import.execute', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - // Get form data with file and options const formData = await request.formData() const file = formData.get('file') as File | null const mappingsJson = formData.get('mappings') as string | null const optionsJson = formData.get('options') as string | null if (!file) { - return NextResponse.json({ error: 'Ingen fil bifogad. Gå tillbaka och ladda upp filen igen.' }, { status: 400 }) + return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId }) } - // Parse options. The voucherSeries option is only a fallback for vouchers - // that arrive without a series (SIE4I subsystem files); the import engine - // preserves each #VER's source series per voucher. - const parsedOptions = optionsJson ? JSON.parse(optionsJson) : null - const { data: companySettings } = await supabase - .from('company_settings') - .select('default_voucher_series') - .eq('company_id', companyId) - .maybeSingle() - const companyDefaultSeries = companySettings?.default_voucher_series || 'B' + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) - const options = parsedOptions ?? { - createFiscalPeriod: true, - importOpeningBalances: true, - importTransactions: true, - voucherSeries: companyDefaultSeries, - } - - // Read and decode file - const arrayBuffer = await file.arrayBuffer() - const encoding = detectEncoding(arrayBuffer) - const content = decodeBuffer(arrayBuffer, encoding) - - // Parse the SIE file - const parsed = parseSIEFile(content) - - // Check for duplicate import before doing any work - const duplicate = await checkDuplicateImport(supabase, companyId, content) - if (duplicate) { - return NextResponse.json({ - error: 'duplicate', - message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : ''}`.trim(), - }, { status: 409 }) - } - - // Get mappings - either from request or generate new ones - let mappings: AccountMapping[] - - if (mappingsJson) { - mappings = JSON.parse(mappingsJson) - } else { - // Match against full BAS reference (not just user's active chart) - const { data: storedMappings } = await supabase - .from('sie_account_mappings') - .select('*') + try { + // The voucherSeries option is a fallback for vouchers that arrive without + // a series (SIE4I subsystem files); the import engine preserves each + // #VER's source series per voucher. + const parsedOptions = optionsJson ? JSON.parse(optionsJson) : null + const { data: companySettings } = await supabase + .from('company_settings') + .select('default_voucher_series') .eq('company_id', companyId) + .maybeSingle() + const companyDefaultSeries = companySettings?.default_voucher_series || 'B' - mappings = suggestMappings( - parsed.accounts, - BAS_REFERENCE, - (storedMappings as SIEAccountMappingRecord[]) || undefined - ) - } - - // Validate all accounts are mapped - const unmapped = mappings.filter((m) => !m.targetAccount) - if (unmapped.length > 0) { - const accountList = unmapped.slice(0, 5).map((m) => `${m.sourceAccount} (${m.sourceName})`).join(', ') - const remaining = unmapped.length > 5 ? ` och ${unmapped.length - 5} till` : '' - return NextResponse.json({ - error: 'validation', - message: `${unmapped.length} konto(n) saknar mappning: ${accountList}${remaining}. Gå tillbaka till kontomappningssteget och koppla alla konton.`, - unmappedAccounts: unmapped.map((m) => ({ - account: m.sourceAccount, - name: m.sourceName, - })), - }, { status: 400 }) - } - - // Auto-activate any mapped BAS accounts not yet in the user's chart - const mappedAccountNumbers = [ - ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), - ] - - const allCompanyAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('account_number') - .eq('company_id', companyId) - .range(from, to) - ) - const mappedSet = new Set(mappedAccountNumbers) - const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number)) - - // Build a lookup from SIE mappings for account names (used for bas_range accounts) - const mappingNameLookup = new Map() - for (const m of mappings) { - if (m.targetAccount) { - mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) + const options = parsedOptions ?? { + createFiscalPeriod: true, + importOpeningBalances: true, + importTransactions: true, + voucherSeries: companyDefaultSeries, } - } - const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) - const accountsToActivate = mappedAccountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) - if (ref) { - // Account exists in BAS reference — use full metadata + const arrayBuffer = await file.arrayBuffer() + const encoding = detectEncoding(arrayBuffer) + const content = decodeBuffer(arrayBuffer, encoding) + + const parsed = parseSIEFile(content) + + const duplicate = await checkDuplicateImport(supabase, companyId!, content) + if (duplicate) { + return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, { + requestId, + details: { importId: duplicate.id, importedAt: duplicate.imported_at }, + }) + } + + let mappings: AccountMapping[] + + if (mappingsJson) { + mappings = JSON.parse(mappingsJson) + } else { + const { data: storedMappings } = await supabase + .from('sie_account_mappings') + .select('*') + .eq('company_id', companyId) + + mappings = suggestMappings( + parsed.accounts, + BAS_REFERENCE, + (storedMappings as SIEAccountMappingRecord[]) || undefined, + ) + } + + const unmapped = mappings.filter((m) => !m.targetAccount) + if (unmapped.length > 0) { + return errorResponseFromCode('SIE_IMPORT_UNMAPPED_ACCOUNTS', opLog, { + requestId, + details: { + unmappedCount: unmapped.length, + unmappedAccounts: unmapped.slice(0, 5).map((m) => ({ + account: m.sourceAccount, + name: m.sourceName, + })), + }, + }) + } + + const mappedAccountNumbers = [ + ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), + ] + + const allCompanyAccounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .range(from, to), + ) + const mappedSet = new Set(mappedAccountNumbers) + const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number)) + + const mappingNameLookup = new Map() + for (const m of mappings) { + if (m.targetAccount) { + mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) + } + } + + const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) + const accountsToActivate = mappedAccountNumbers + .filter((num) => !existingNumbers.has(num)) + .map((num) => { + const ref = getBASReference(num) + if (ref) { + 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), + } + } + + // Sub-account not in BAS reference (e.g. 1241 Personbilar). Derive + // metadata from the account number. + const accountClass = parseInt(num.charAt(0), 10) + const accountGroup = num.substring(0, 2) + const accountName = mappingNameLookup.get(num) || `Konto ${num}` + const accountType = + accountClass === 1 ? 'asset' + : accountClass === 2 ? 'liability' + : accountClass === 3 ? 'revenue' + : 'expense' + const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' + 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, + account_number: num, + account_name: accountName, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, 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), + description: accountName, + sru_code: null, + sort_order: parseInt(num), } - } + }) - // Account not in BAS reference (sub-account like 1241 Personbilar). - // Derive metadata from the account number. - const accountClass = parseInt(num.charAt(0), 10) - const accountGroup = num.substring(0, 2) - const accountName = mappingNameLookup.get(num) || `Konto ${num}` - const accountType = - accountClass === 1 ? 'asset' - : accountClass === 2 ? 'liability' - : accountClass === 3 ? 'revenue' - : 'expense' - const normalBalance = - accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' + if (accountsToActivate.length > 0) { + const { error: activateError } = await supabase + .from('chart_of_accounts') + .insert(accountsToActivate) - return { - user_id: user.id, - company_id: companyId, - account_number: num, - account_name: accountName, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: accountName, - sru_code: null, - sort_order: parseInt(num), + if (activateError) { + opLog.error('sie account activation failed', activateError) + return errorResponseFromCode('SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED', opLog, { + requestId, + details: { reason: activateError.message }, + }) } + } + + const result = await executeSIEImport( + supabase, + companyId!, + user.id, + parsed, + mappings, + { + filename: file.name, + fileContent: content, + createFiscalPeriod: options.createFiscalPeriod, + importOpeningBalances: options.importOpeningBalances, + importTransactions: options.importTransactions, + voucherSeries: options.voucherSeries || companyDefaultSeries, + }, + ) + + if (!result.success) { + return errorResponseFromCode('SIE_IMPORT_FAILED', opLog, { + requestId, + details: { result }, + }) + } + + return NextResponse.json({ success: true, result }) + } catch (err) { + opLog.error('sie execute unexpected error', err as Error) + return errorResponseFromCode('SIE_IMPORT_UNEXPECTED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, }) - - if (accountsToActivate.length > 0) { - const { error: activateError } = await supabase - .from('chart_of_accounts') - .insert(accountsToActivate) - - if (activateError) { - return NextResponse.json({ - error: `Kunde inte aktivera konton i kontoplanen: ${activateError.message}. Kontrollera att kontona inte redan finns med andra inställningar.`, - }, { status: 500 }) - } } - - // Execute the import - const result = await executeSIEImport( - supabase, - companyId, - user.id, - parsed, - mappings, - { - filename: file.name, - fileContent: content, - createFiscalPeriod: options.createFiscalPeriod, - importOpeningBalances: options.importOpeningBalances, - importTransactions: options.importTransactions, - voucherSeries: options.voucherSeries || companyDefaultSeries, - } - ) - - if (!result.success) { - return NextResponse.json({ - error: 'import', - message: 'Importen slutfördes med fel. Se detaljerna nedan för att förstå vad som gick snett.', - result, - }, { status: 400 }) - } - - return NextResponse.json({ - success: true, - result, - }) - } catch (error) { - console.error('SIE import error:', error) - const detail = error instanceof Error ? error.message : '' - return NextResponse.json( - { - error: `Importen avbröts oväntat. Ingen data har sparats.${detail ? ` (${detail})` : ''} Försök igen — om felet kvarstår, kontakta support.`, - }, - { status: 500 } - ) - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index 53cda865..8b7cc951 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -1,6 +1,4 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { parseSIEFile, validateSIEFile, @@ -11,170 +9,142 @@ import { import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper' import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SIEAccountMappingRecord } from '@/lib/import/types' /** * POST /api/import/sie/parse - * Parse an uploaded SIE file and return preview data + * Parse an uploaded SIE file and return preview data. */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'sie_import.parse', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - try { - // Get form data with file const formData = await request.formData() const file = formData.get('file') as File | null if (!file) { - return NextResponse.json({ error: 'parse', message: 'Ingen fil bifogad i förfrågan.' }, { status: 400 }) + return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId }) } - // Validate file type const filename = file.name.toLowerCase() if (!filename.endsWith('.sie') && !filename.endsWith('.se')) { - return NextResponse.json( - { error: 'parse', message: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.' }, - { status: 400 } - ) + return errorResponseFromCode('SIE_PARSE_INVALID_TYPE', log, { + requestId, + details: { filename: file.name }, + }) } - // Validate file size (max 50 MB) const MAX_FILE_SIZE = 50 * 1024 * 1024 if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: 'parse', message: `Filen är för stor (${(file.size / 1024 / 1024).toFixed(1)} MB). Maxstorlek är 50 MB.` }, - { status: 400 } - ) + return errorResponseFromCode('SIE_PARSE_FILE_TOO_LARGE', log, { + requestId, + details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) }, + }) } - // Validate file is not empty if (file.size === 0) { - return NextResponse.json( - { error: 'parse', message: 'Filen är tom (0 bytes). Kontrollera att exporten från bokföringsprogrammet genomfördes korrekt.' }, - { status: 400 } - ) + return errorResponseFromCode('SIE_PARSE_EMPTY', log, { requestId }) } - // Read file as ArrayBuffer for encoding detection - const arrayBuffer = await file.arrayBuffer() - const encoding = detectEncoding(arrayBuffer) + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) - // Decode to string - const content = decodeBuffer(arrayBuffer, encoding) + try { + const arrayBuffer = await file.arrayBuffer() + const encoding = detectEncoding(arrayBuffer) + const content = decodeBuffer(arrayBuffer, encoding) - // Check for duplicate import (by file hash) - const duplicate = await checkDuplicateImport(supabase, companyId, content) - if (duplicate) { - return NextResponse.json({ - error: 'duplicate', - message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`, - importId: duplicate.id, - }, { status: 409 }) - } - - // Parse the SIE file - const parsed = parseSIEFile(content) - - // Check for existing import covering the same fiscal period - if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) { - const periodDuplicate = await checkDuplicatePeriodImport( - supabase, - companyId, - parsed.stats.fiscalYearStart, - parsed.stats.fiscalYearEnd - ) - if (periodDuplicate) { - return NextResponse.json({ - error: 'duplicate_period', - message: `En SIE-import för ett överlappande räkenskapsår (${periodDuplicate.fiscal_year_start} – ${periodDuplicate.fiscal_year_end}) finns redan (importerad ${periodDuplicate.imported_at ? new Date(periodDuplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'})`, - importId: periodDuplicate.id, - }, { status: 409 }) + const duplicate = await checkDuplicateImport(supabase, companyId!, content) + if (duplicate) { + return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, { + requestId, + details: { + importId: duplicate.id, + importedAt: duplicate.imported_at, + }, + }) } - } - // Validate the parsed data - const validation = validateSIEFile(parsed) + const parsed = parseSIEFile(content) + + if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) { + const periodDuplicate = await checkDuplicatePeriodImport( + supabase, + companyId!, + parsed.stats.fiscalYearStart, + parsed.stats.fiscalYearEnd, + ) + if (periodDuplicate) { + return errorResponseFromCode('SIE_DUPLICATE_PERIOD', opLog, { + requestId, + details: { + importId: periodDuplicate.id, + fiscalYearStart: periodDuplicate.fiscal_year_start, + fiscalYearEnd: periodDuplicate.fiscal_year_end, + importedAt: periodDuplicate.imported_at, + }, + }) + } + } + + const validation = validateSIEFile(parsed) + + if (!validation.valid) { + return errorResponseFromCode('SIE_PARSE_VALIDATION_FAILED', opLog, { + requestId, + details: { errors: validation.errors, warnings: validation.warnings }, + }) + } + + const excludedSystemAccounts = parsed.accounts + .filter((a) => isSystemAccount(a.number)) + .map((a) => ({ number: a.number, name: a.name })) + const bookkeepingAccounts = parsed.accounts.filter((a) => !isSystemAccount(a.number)) + + const { data: storedMappings } = await supabase + .from('sie_account_mappings') + .select('*') + .eq('company_id', companyId) + + const mappings = suggestMappings( + bookkeepingAccounts, + BAS_REFERENCE, + (storedMappings as SIEAccountMappingRecord[]) || undefined, + ) + + const preview = generateImportPreview(parsed, mappings) + preview.excludedSystemAccounts = excludedSystemAccounts + preview.accountCount = bookkeepingAccounts.length + + const fileHash = await calculateFileHash(content) - // If there are critical errors, return them - if (!validation.valid) { return NextResponse.json({ - error: 'validation', - message: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.', - errors: validation.errors, - warnings: validation.warnings, - }, { status: 400 }) + success: true, + encoding, + fileHash, + parsed: { + header: parsed.header, + accounts: parsed.accounts, + stats: parsed.stats, + issues: parsed.issues, + }, + mappings, + mappingStats: getMappingStats(mappings), + preview, + validation: { + valid: validation.valid, + errors: validation.errors, + warnings: validation.warnings, + }, + }) + } catch (err) { + opLog.error('sie parse failed', err as Error) + return errorResponseFromCode('SIE_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) } - - // Separate source-system internal accounts (e.g. Fortnox 0099) from - // real bookkeeping accounts. System accounts have no BAS equivalent and - // should not appear in the mapping step. - const excludedSystemAccounts = parsed.accounts - .filter((a) => isSystemAccount(a.number)) - .map((a) => ({ number: a.number, name: a.name })) - const bookkeepingAccounts = parsed.accounts - .filter((a) => !isSystemAccount(a.number)) - - // Fetch stored mappings from database - const { data: storedMappings } = await supabase - .from('sie_account_mappings') - .select('*') - .eq('company_id', companyId) - - // Match against the full BAS reference (1,276 accounts) instead of only - // the user's active chart (~40 accounts). Accounts that match will be - // auto-activated during the execute step. - const mappings = suggestMappings( - bookkeepingAccounts, - BAS_REFERENCE, - (storedMappings as SIEAccountMappingRecord[]) || undefined - ) - - // Generate preview - const preview = generateImportPreview(parsed, mappings) - preview.excludedSystemAccounts = excludedSystemAccounts - preview.accountCount = bookkeepingAccounts.length - - // Calculate file hash for storage - const fileHash = await calculateFileHash(content) - - return NextResponse.json({ - success: true, - encoding, - fileHash, - parsed: { - header: parsed.header, - accounts: parsed.accounts, - stats: parsed.stats, - issues: parsed.issues, - }, - mappings, - mappingStats: getMappingStats(mappings), - preview, - validation: { - valid: validation.valid, - errors: validation.errors, - warnings: validation.warnings, - }, - }) - } catch (error) { - console.error('SIE parse error:', error) - const detail = error instanceof Error ? error.message : '' - return NextResponse.json( - { - error: 'parse', - message: `Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.${detail ? ` (${detail})` : ''}`, - }, - { status: 500 } - ) - } -} + }, +) diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 22144a21..ecc72694 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -74,7 +74,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Fakturan hittades inte') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND') }) it('returns 400 when invoice is in draft status', async () => { @@ -86,7 +86,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_PAYABLE') }) it('returns 400 when invoice is already paid', async () => { @@ -98,7 +98,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_PAYABLE') }) it('returns 400 when invoice is credited', async () => { @@ -279,7 +279,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toContain('balanserade') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_LINES_UNBALANCED') expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 2aee05bf..b22d51bc 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -1,15 +1,14 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' ensureInitialized() @@ -19,211 +18,188 @@ ensureInitialized() * * Manually marks an invoice as paid (for payments received outside bank sync). * - * Faktureringsmetoden (accrual): - * Creates payment clearing entry: Debit 1930, Credit 1510 - * - * Kontantmetoden (cash): - * Creates combined revenue entry: Debit 1930, Credit 30xx, Credit 26xx + * Faktureringsmetoden (accrual): Debit 1930, Credit 1510 (clearing entry) + * Kontantmetoden (cash): Debit 1930, Credit 30xx, Credit 26xx */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() +export const POST = withRouteContext( + 'invoice.mark_paid', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ invoiceId: id }) - const { data: { user } } = await supabase.auth.getUser() + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch invoice - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (invoiceError || !invoice) { - return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 }) - } - - if (invoice.status !== 'sent' && invoice.status !== 'overdue') { - return NextResponse.json( - { error: 'Fakturan kan inte markeras som betald i nuvarande status' }, - { status: 400 } - ) - } - - // Parse optional body (backward compatible — body may be empty) - let exchangeRateDifference: number | undefined - let bodyPaymentDate: string | undefined - let customLines: { account_number: string; debit_amount: number; credit_amount: number; line_description?: string }[] | undefined - let rawBody: unknown - try { - const text = await request.text() - if (text) rawBody = JSON.parse(text) - } catch { - // No body or invalid JSON — use defaults - } - - if (rawBody) { - const parsed = MarkInvoicePaidSchema.safeParse(rawBody) - if (!parsed.success) { - return NextResponse.json({ error: 'Ogiltig förfrågan', details: parsed.error.flatten() }, { status: 400 }) + if (invoiceError || !invoice) { + return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) } - exchangeRateDifference = parsed.data.exchange_rate_difference - bodyPaymentDate = parsed.data.payment_date - customLines = parsed.data.lines - } - const now = new Date().toISOString() - const paymentDate = bodyPaymentDate || now.split('T')[0] + if (invoice.status !== 'sent' && invoice.status !== 'overdue') { + return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, { + requestId, + details: { currentStatus: invoice.status }, + }) + } - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method, entity_type') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - // Create journal entry FIRST — only mark paid if accounting succeeds - const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' - let journalEntryId: string | null = null - - if (isRealInvoice) { + // Optional body. Backwards-compat: callers may POST with no body. + let exchangeRateDifference: number | undefined + let bodyPaymentDate: string | undefined + let customLines: { account_number: string; debit_amount: number; credit_amount: number; line_description?: string }[] | undefined + let rawBody: unknown try { - if (customLines) { - // Server-side balance validation — never commit imbalanced entries - const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0) - const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0) - if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) { - return NextResponse.json( - { error: 'Verifikationsraderna är inte balanserade (debet ≠ kredit)' }, - { status: 400 } - ) - } - - // User-provided lines from PaymentBookingDialog - const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate) - if (!fiscalPeriodId) { - return NextResponse.json( - { error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' }, - { status: 400 } - ) - } - const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment' - const input: CreateJournalEntryInput = { - fiscal_period_id: fiscalPeriodId, - entry_date: paymentDate, - description: invoice.customer?.name - ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` - : `Inbetalning kundfaktura ${invoice.invoice_number}`, - source_type: sourceType, - source_id: invoice.id, - lines: customLines, - } - const journalEntry = await createJournalEntry(supabase, companyId, user.id, input) - journalEntryId = journalEntry?.id ?? null - } else if (accountingMethod === 'accrual') { - // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - paymentDate, - exchangeRateDifference, - invoice.customer?.name - ) - journalEntryId = journalEntry?.id ?? null - } else { - // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) - const journalEntry = await createInvoiceCashEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - paymentDate, - entityType, - invoice.customer?.name - ) - journalEntryId = journalEntry?.id ?? null - } - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - console.error('Failed to create payment journal entry:', err) - return NextResponse.json( - { error: 'Kunde inte bokföra betalningen' }, - { status: 500 } - ) + const text = await request.text() + if (text) rawBody = JSON.parse(text) + } catch { + // Empty / invalid body — fall through to defaults. } - } - // Update status to paid (CAS guard: only if still in payable status) - const { data: updateResult, error: updateError } = await supabase - .from('invoices') - .update({ - status: 'paid', - paid_at: now, - paid_amount: invoice.total, - }) - .eq('id', id) - .eq('company_id', companyId) - .in('status', ['sent', 'overdue']) - .select('id') + if (rawBody) { + const parsed = MarkInvoicePaidSchema.safeParse(rawBody) + if (!parsed.success) { + opLog.warn('mark-paid validation failed', { + issueCount: parsed.error.issues.length, + }) + return NextResponse.json( + { error: 'Ogiltig förfrågan', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + exchangeRateDifference = parsed.data.exchange_rate_difference + bodyPaymentDate = parsed.data.payment_date + customLines = parsed.data.lines + } - if (updateError) { - return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) - } + const now = new Date().toISOString() + const paymentDate = bodyPaymentDate || now.split('T')[0] - // CAS guard: status changed between our read and write - if (!updateResult || updateResult.length === 0) { - if (journalEntryId) { - const { data: orphan } = await supabase - .from('journal_entries') - .select('fiscal_period_id, voucher_series, voucher_number') - .eq('id', journalEntryId) - .single() + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', companyId) + .single() - await supabase - .from('journal_entries') - .update({ status: 'cancelled' }) - .eq('id', journalEntryId) + const accountingMethod = settings?.accounting_method || 'accrual' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - if (orphan) { - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series || 'A', - gap_number: orphan.voucher_number, - explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - created_by: user.id, + const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + let journalEntryId: string | null = null + + if (isRealInvoice) { + try { + if (customLines) { + const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0) + const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0) + if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) { + return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, { + requestId, + details: { totalDebit, totalCredit }, + }) + } + + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate) + if (!fiscalPeriodId) { + return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, { + requestId, + details: { paymentDate }, + }) + } + const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment' + const input: CreateJournalEntryInput = { + fiscal_period_id: fiscalPeriodId, + entry_date: paymentDate, + description: invoice.customer?.name + ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` + : `Inbetalning kundfaktura ${invoice.invoice_number}`, + source_type: sourceType, + source_id: invoice.id, + lines: customLines, + } + const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input) + journalEntryId = journalEntry?.id ?? null + } else if (accountingMethod === 'accrual') { + const journalEntry = await createInvoicePaymentJournalEntry( + supabase, companyId!, user.id, invoice as Invoice, paymentDate, + exchangeRateDifference, invoice.customer?.name, + ) + journalEntryId = journalEntry?.id ?? null + } else { + const journalEntry = await createInvoiceCashEntry( + supabase, companyId!, user.id, invoice as Invoice, paymentDate, + entityType, invoice.customer?.name, + ) + journalEntryId = journalEntry?.id ?? null + } + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('failed to create payment journal entry', err as Error) + return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, }) } } - return NextResponse.json( - { error: 'Fakturan har redan betalats av en annan förfrågan' }, - { status: 409 } - ) - } - return NextResponse.json({ - success: true, - status: 'paid', - paid_at: now, - paid_amount: invoice.total, - journal_entry_id: journalEntryId, - }) -} + // CAS guard: only update if status is still in a payable state. + const { data: updateResult, error: updateError } = await supabase + .from('invoices') + .update({ + status: 'paid', + paid_at: now, + paid_amount: invoice.total, + }) + .eq('id', id) + .eq('company_id', companyId) + .in('status', ['sent', 'overdue']) + .select('id') + + if (updateError) { + opLog.error('failed to update invoice status', updateError) + return errorResponse(updateError, opLog, { requestId }) + } + + if (!updateResult || updateResult.length === 0) { + // Status changed between read and write — cancel the orphaned JE and + // document the voucher gap before reporting back. + if (journalEntryId) { + const { data: orphan } = await supabase + .from('journal_entries') + .select('fiscal_period_id, voucher_series, voucher_number') + .eq('id', journalEntryId) + .single() + + await supabase + .from('journal_entries') + .update({ status: 'cancelled' }) + .eq('id', journalEntryId) + + if (orphan) { + await supabase.from('voucher_gap_explanations').insert({ + company_id: companyId, + fiscal_period_id: orphan.fiscal_period_id, + voucher_series: orphan.voucher_series || 'A', + gap_number: orphan.voucher_number, + explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + created_by: user.id, + }) + } + } + return errorResponseFromCode('INVOICE_PAID_RACE', opLog, { requestId }) + } + + return NextResponse.json({ + success: true, + status: 'paid', + paid_at: now, + paid_amount: invoice.total, + journal_entry_id: journalEntryId, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index a9d4c10b..4eca859c 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -128,7 +128,7 @@ describe('POST /api/invoices/[id]/send', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Fakturan hittades inte') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND') }) it('returns 400 when customer has no email', async () => { @@ -144,7 +144,7 @@ describe('POST /api/invoices/[id]/send', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toContain('e-postadress') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') }) it('returns 404 when company settings not found', async () => { @@ -156,7 +156,7 @@ describe('POST /api/invoices/[id]/send', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Företagsinställningar saknas') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING') }) it('sends invoice email, updates status, creates journal entry for accrual', async () => { @@ -313,7 +313,11 @@ describe('POST /api/invoices/[id]/send', () => { const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) - expect(status).toBe(500) - expect(body.error).toContain('SMTP error') + // Provider errors map to 502 PROVIDER_FAILED with the provider message in details. + expect(status).toBe(502) + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED') + expect( + (body.error as unknown as { details?: { providerError?: string } }).details?.providerError, + ).toContain('SMTP error') }) }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 9178f350..18403dcb 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -1,4 +1,3 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' @@ -8,118 +7,88 @@ import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, generateInvoiceEmailText, - generateInvoiceEmailSubject + generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { uploadDocument } from '@/lib/core/documents/document-service' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() +export const POST = withRouteContext( + 'invoice.send', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ invoiceId: id }) - const { data: { user } } = await supabase.auth.getUser() + const emailService = getEmailService() + if (!emailService.isConfigured()) { + return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId }) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Check if email is configured - const emailService = getEmailService() - if (!emailService.isConfigured()) { - return NextResponse.json( - { error: 'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta i miljövariablerna.' }, - { status: 503 } - ) - } - - // Fetch invoice with customer and items - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select(` - *, - customer:customers(*), - items:invoice_items(*) - `) - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (invoiceError || !invoice) { - return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 }) - } - - // Verify customer has email - const customer = invoice.customer as Customer - if (!customer.email) { - return NextResponse.json( - { error: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.' }, - { status: 400 } - ) - } - - // Fetch company settings - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', companyId) - .single() - - if (companyError || !company) { - return NextResponse.json( - { error: 'Företagsinställningar saknas' }, - { status: 404 } - ) - } - - // Assign invoice number now if this is a draft being sent for the first time. - // Mutates `invoice.invoice_number` so the rest of this flow (PDF render, - // email subject, journal entry description) sees the new value. - try { - await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) - } catch (err) { - console.error('Failed to assign invoice number on send:', err) - return NextResponse.json( - { error: 'Kunde inte tilldela fakturanummer. Försök igen.' }, - { status: 500 } - ) - } - - // Sort items by sort_order - const items = (invoice.items as InvoiceItem[]).sort( - (a, b) => a.sort_order - b.sort_order - ) - - // If this is a credit note, fetch the original invoice number - let originalInvoiceNumber: string | undefined - if (invoice.credited_invoice_id) { - const { data: originalInvoice } = await supabase + const { data: invoice, error: invoiceError } = await supabase .from('invoices') - .select('invoice_number') - .eq('id', invoice.credited_invoice_id) + .select(` + *, + customer:customers(*), + items:invoice_items(*) + `) + .eq('id', id) .eq('company_id', companyId) .single() - if (originalInvoice) { - originalInvoiceNumber = originalInvoice.invoice_number + if (invoiceError || !invoice) { + return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) } - } - try { - // Generate PDF + const customer = invoice.customer as Customer + if (!customer.email) { + return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { + requestId, + details: { customerId: customer.id }, + }) + } + + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single() + + if (companyError || !company) { + return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId }) + } + + // Eagerly assign the invoice number — drafts get one only at send time so + // discarded drafts never consume a number. + try { + await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice) + } catch (err) { + opLog.error('failed to assign invoice number on send', err as Error) + return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId }) + } + + const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order) + + let originalInvoiceNumber: string | undefined + if (invoice.credited_invoice_id) { + const { data: originalInvoice } = await supabase + .from('invoices') + .select('invoice_number') + .eq('id', invoice.credited_invoice_id) + .eq('company_id', companyId) + .single() + + if (originalInvoice) { + originalInvoiceNumber = originalInvoice.invoice_number + } + } + + // Generate PDF — non-fatal failures here become PARTIAL after send. const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: invoice as Invoice, @@ -127,17 +96,15 @@ export async function POST( items, company: company as CompanySettings, originalInvoiceNumber, - }) + }), ) - // Prepare email data const emailData = { invoice: invoice as Invoice, customer, - company: company as CompanySettings + company: company as CompanySettings, } - // Determine filename based on document type const isCreditNote = !!invoice.credited_invoice_id const docType = invoice.document_type || 'invoice' let filename: string @@ -151,7 +118,6 @@ export async function POST( filename = `faktura-${invoice.invoice_number}.pdf` } - // Send email (CC the user so they have a copy of what was sent) const ccAddress = company.email || user.email const result = await emailService.sendEmail({ to: customer.email, @@ -165,42 +131,50 @@ export async function POST( { filename, content: pdfBuffer, - contentType: 'application/pdf' - } - ] + contentType: 'application/pdf', + }, + ], }) if (!result.success) { - console.error('Failed to send invoice email:', result.error) - return NextResponse.json( - { error: `Kunde inte skicka e-post: ${result.error}` }, - { status: 500 } - ) + opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown')) + return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, { + requestId, + details: { providerError: result.error }, + }) } - // Update invoice status to "sent" - const { error: updateError } = await supabase - .from('invoices') - .update({ status: 'sent' }) - .eq('id', id) - .eq('company_id', companyId) + // From here on the invoice has reached the customer. Failures in the + // follow-up steps degrade the response to PARTIAL — the user gets a + // success toast with a sub-warning, and the audit trail records exactly + // which sub-step broke. + const partialFailures: Array<{ step: string; reason: string }> = [] - if (updateError) { - console.error('Failed to update invoice status:', updateError) - // Don't fail the request - the email was sent successfully + { + const { error: updateError } = await supabase + .from('invoices') + .update({ status: 'sent' }) + .eq('id', id) + .eq('company_id', companyId) + + if (updateError) { + opLog.warn('failed to update invoice status to sent', updateError) + partialFailures.push({ step: 'status_update', reason: updateError.message }) + } } - // Only create journal entries for real invoices (not proformas or delivery notes) const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + const accountingMethod = (company as Record).accounting_method as string | undefined let createdJournalEntryId: string | undefined - if (isRealInvoice && ((company as Record).accounting_method === 'accrual' || !(company as Record).accounting_method)) { + + if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) { try { const journalEntry = await createInvoiceJournalEntry( supabase, - companyId, + companyId!, user.id, invoice as Invoice, - (company as CompanySettings).entity_type + (company as CompanySettings).entity_type, ) if (journalEntry) { createdJournalEntryId = journalEntry.id @@ -210,16 +184,18 @@ export async function POST( .eq('id', id) } } catch (err) { - console.error('Failed to create invoice journal entry on send:', err) - // Non-blocking — don't fail the send + opLog.error('failed to create invoice journal entry on send', err as Error) + partialFailures.push({ + step: 'journal_entry', + reason: err instanceof Error ? err.message : 'unknown', + }) } } - // Auto-store invoice PDF as underlag and link to journal entry if (isRealInvoice) { try { const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer - await uploadDocument(supabase, user.id, companyId, { + await uploadDocument(supabase, user.id, companyId!, { name: filename, buffer: pdfArrayBuffer, type: 'application/pdf', @@ -228,26 +204,34 @@ export async function POST( journal_entry_id: createdJournalEntryId, }) } catch (err) { - console.error('Failed to store invoice PDF as underlag:', err) - // Non-blocking — don't fail the send + opLog.error('failed to store invoice PDF as underlag', err as Error) + partialFailures.push({ + step: 'pdf_archive', + reason: err instanceof Error ? err.message : 'unknown', + }) } } await eventBus.emit({ type: 'invoice.sent', - payload: { invoice: invoice as Invoice, companyId, userId: user.id }, + payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id }, }) + if (partialFailures.length > 0) { + opLog.warn('invoice sent with partial follow-up failures', { + errorCode: 'INVOICE_SEND_PARTIAL', + failures: partialFailures, + }) + } + return NextResponse.json({ success: true, message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`, - messageId: result.messageId + messageId: result.messageId, + ...(partialFailures.length > 0 + ? { partial: true, partial_failures: partialFailures } + : {}), }) - } catch (error) { - console.error('Send invoice error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Kunde inte skicka fakturan' }, - { status: 500 } - ) - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 47927e7c..7a8327be 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -116,7 +116,8 @@ describe('GET /api/invoices', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('DB error') + // GET passes through errorResponse which maps unknown DB errors to INTERNAL_ERROR + expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') }) }) @@ -164,7 +165,7 @@ describe('POST /api/invoices (create invoice)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Customer not found') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CUSTOMER_NOT_FOUND') }) it('creates invoice with items and emits event', async () => { @@ -255,7 +256,7 @@ describe('POST /api/invoices (create invoice)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Items insert failed') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED') }) }) @@ -280,7 +281,7 @@ describe('POST /api/invoices (create credit note)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Original invoice not found') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_ORIGINAL_NOT_FOUND') }) it('returns 400 when invoice is already credited', async () => { @@ -295,7 +296,7 @@ describe('POST /api/invoices (create credit note)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Invoice has already been credited') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_ALREADY_CREDITED') }) it('returns 400 when invoice is in draft status', async () => { @@ -310,7 +311,7 @@ describe('POST /api/invoices (create credit note)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Only sent, paid, or overdue invoices can be credited') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT') }) it('creates credit note with negated amounts and emits event', async () => { @@ -416,6 +417,6 @@ describe('POST /api/invoices (create credit note)', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Items insert failed') + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED') }) }) diff --git a/app/api/invoices/reminders/cron/route.ts b/app/api/invoices/reminders/cron/route.ts index 5ac84d76..d29b04ca 100644 --- a/app/api/invoices/reminders/cron/route.ts +++ b/app/api/invoices/reminders/cron/route.ts @@ -1,50 +1,42 @@ import { NextResponse } from 'next/server' import { processOverdueReminders } from '@/lib/invoices/reminder-processor' import { getEmailService } from '@/lib/email/service' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - - // Check if email service is configured +/** + * GET/POST /api/invoices/reminders/cron — daily 08:00 UTC. + * Sends overdue invoice reminders. POST exists so the dashboard can + * trigger a run manually. + */ +export const GET = withCronContext('cron.invoice_reminders', async (_request, ctx) => { if (!getEmailService().isConfigured()) { - console.error('Email service not configured, skipping reminder cron') - return NextResponse.json({ - success: false, - error: 'Email service not configured' - }, { status: 503 }) - } - - try { - console.log('Starting invoice reminder cron job...') - - const result = await processOverdueReminders() - - console.log(`Reminder cron completed: ${result.sent} sent, ${result.failed} failed out of ${result.processed} processed`) - - return NextResponse.json({ - success: true, - processed: result.processed, - sent: result.sent, - failed: result.failed, - results: result.results.map(r => ({ - invoiceNumber: r.invoiceNumber, - reminderLevel: r.reminderLevel, - success: r.success, - error: r.error - })) + ctx.log.error('email service not configured; skipping reminder run') + return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', ctx.log, { + requestId: ctx.requestId, }) - } catch (error) { - console.error('Invoice reminder cron job error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Cron job failed' }, - { status: 500 } - ) } -} -// Also support POST for manual triggering via dashboard -export async function POST(request: Request) { - return GET(request) -} + const result = await processOverdueReminders() + + ctx.log.info('reminder cron summary', { + processed: result.processed, + sent: result.sent, + failed: result.failed, + }) + + return NextResponse.json({ + success: true, + processed: result.processed, + sent: result.sent, + failed: result.failed, + results: result.results.map((r) => ({ + invoiceNumber: r.invoiceNumber, + reminderLevel: r.reminderLevel, + success: r.success, + error: r.error, + })), + }) +}) + +export const POST = GET diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index f676a9b8..f7578f3e 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -1,83 +1,87 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas' import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' -import { - createCreditNoteJournalEntry, -} from '@/lib/bookkeeping/invoice-entries' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Logger } from '@/lib/logger' ensureInitialized() -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'invoice.list', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { searchParams } = new URL(request.url) + const status = searchParams.get('status') + const limit = parseInt(searchParams.get('limit') || '50') + const offset = parseInt(searchParams.get('offset') || '0') - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + let query = supabase + .from('invoices') + .select('*, customer:customers(*)', { count: 'exact' }) + .eq('company_id', companyId) + .order('invoice_date', { ascending: false }) + .range(offset, offset + limit - 1) - const companyId = await requireCompanyId(supabase, user.id) + if (status) { + query = query.eq('status', status) + } - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') - const limit = parseInt(searchParams.get('limit') || '50') - const offset = parseInt(searchParams.get('offset') || '0') + const { data, error, count } = await query - let query = supabase - .from('invoices') - .select('*, customer:customers(*)', { count: 'exact' }) - .eq('company_id', companyId) - .order('invoice_date', { ascending: false }) - .range(offset, offset + limit - 1) + if (error) { + log.error('failed to list invoices', error) + return errorResponse(error, log, { requestId }) + } - if (status) { - query = query.eq('status', status) - } + return NextResponse.json({ data, count }) + }, +) - const { data, error, count } = await query +export const POST = withRouteContext( + 'invoice.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + log.warn('invalid json body', { kind: 'json' }) + return NextResponse.json( + { error: 'Invalid JSON in request body', type: 'validation_error' }, + { status: 400 }, + ) + } - return NextResponse.json({ data, count }) -} + if (typeof rawBody === 'object' && rawBody !== null && 'credited_invoice_id' in rawBody) { + const parsed = CreateCreditNoteSchema.safeParse(rawBody) + if (!parsed.success) { + log.warn('credit note validation failed', { + issueCount: parsed.error.issues.length, + }) + return NextResponse.json( + { + error: 'Validation failed', + type: 'validation_error', + errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })), + }, + { status: 400 }, + ) + } + return createCreditNote(supabase, companyId!, user.id, parsed.data, log, requestId) + } -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - let rawBody: unknown - try { - rawBody = await request.json() - } catch { - return NextResponse.json( - { error: 'Invalid JSON in request body', type: 'validation_error' }, - { status: 400 }, - ) - } - - // Check if this is a credit note creation request - if (typeof rawBody === 'object' && rawBody !== null && 'credited_invoice_id' in rawBody) { - const parsed = CreateCreditNoteSchema.safeParse(rawBody) + const parsed = CreateInvoiceSchema.safeParse(rawBody) if (!parsed.success) { + log.warn('invoice validation failed', { issueCount: parsed.error.issues.length }) return NextResponse.json( { error: 'Validation failed', @@ -87,187 +91,174 @@ export async function POST(request: Request) { { status: 400 }, ) } - return createCreditNote(supabase, companyId, user.id, parsed.data) - } + const invoiceInput = parsed.data + const documentType: InvoiceDocumentType = invoiceInput.document_type || 'invoice' - const parsed = CreateInvoiceSchema.safeParse(rawBody) - if (!parsed.success) { - return NextResponse.json( - { - error: 'Validation failed', - type: 'validation_error', - errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })), - }, - { status: 400 }, - ) - } - const invoiceInput = parsed.data - const documentType: InvoiceDocumentType = invoiceInput.document_type || 'invoice' + const { data: customer, error: customerError } = await supabase + .from('customers') + .select('*') + .eq('id', invoiceInput.customer_id) + .eq('company_id', companyId!) + .single() - // Get customer for VAT calculation - const { data: customer, error: customerError } = await supabase - .from('customers') - .select('*') - .eq('id', invoiceInput.customer_id) - .eq('company_id', companyId) - .single() + if (customerError || !customer) { + return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, { + requestId, + details: { customerId: invoiceInput.customer_id }, + }) + } - if (customerError || !customer) { - return NextResponse.json({ error: 'Customer not found' }, { status: 404 }) - } + const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) + const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated) + const allowedRates = new Set(availableRates.map((r) => r.rate)) - // Calculate VAT rules (default for customer) - const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) - const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated) - const allowedRates = new Set(availableRates.map((r) => r.rate)) + const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) - // Calculate per-item VAT and subtotals - const subtotal = invoiceInput.items.reduce((sum, item) => { - return sum + item.quantity * item.unit_price - }, 0) - - // Calculate VAT per item, respecting per-line vat_rate - let vatAmount = 0 - if (documentType !== 'delivery_note') { - for (const item of invoiceInput.items) { - const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - // Validate rate is allowed for this customer - if (!allowedRates.has(itemRate)) { - return NextResponse.json( - { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp` }, - { status: 400 } - ) + let vatAmount = 0 + if (documentType !== 'delivery_note') { + for (const item of invoiceInput.items) { + const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate + if (!allowedRates.has(itemRate)) { + return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, { + requestId, + details: { + attemptedRate: itemRate, + allowedRates: Array.from(allowedRates), + customerType: customer.customer_type, + }, + }) + } + const lineTotal = item.quantity * item.unit_price + vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } + } + const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount + + const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate)) + const isMixedRate = uniqueRates.size > 1 + + let exchangeRate: number | null = null + let exchangeRateDate: string | null = null + let subtotalSek: number | null = null + let vatAmountSek: number | null = null + let totalSek: number | null = null + + if (invoiceInput.currency !== 'SEK') { + const rateData = await fetchExchangeRate(invoiceInput.currency) + if (rateData) { + exchangeRate = rateData.rate + exchangeRateDate = rateData.date + subtotalSek = convertToSEK(subtotal, exchangeRate) + vatAmountSek = convertToSEK(vatAmount, exchangeRate) + totalSek = convertToSEK(total, exchangeRate) + } + } + + let invoiceNumber: string | null = null + if (documentType === 'delivery_note') { + const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', { + p_company_id: companyId, + }) + invoiceNumber = dnNumber + } + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .insert({ + user_id: user.id, + company_id: companyId, + customer_id: invoiceInput.customer_id, + invoice_number: invoiceNumber, + invoice_date: invoiceInput.invoice_date, + due_date: invoiceInput.due_date, + delivery_date: invoiceInput.delivery_date ?? null, + currency: invoiceInput.currency, + exchange_rate: exchangeRate, + exchange_rate_date: exchangeRateDate, + subtotal: documentType === 'delivery_note' ? 0 : subtotal, + subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, + total, + total_sek: documentType === 'delivery_note' ? null : totalSek, + vat_treatment: vatRules.treatment, + vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)), + moms_ruta: vatRules.momsRuta, + reverse_charge_text: vatRules.reverseChargeText || null, + your_reference: invoiceInput.your_reference, + our_reference: invoiceInput.our_reference, + notes: invoiceInput.notes, + document_type: documentType, + }) + .select() + .single() + + if (invoiceError) { + log.error('invoice insert failed', invoiceError) + return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, { + requestId, + details: { pgCode: invoiceError.code, pgMessage: invoiceError.message }, + }) + } + + const items = invoiceInput.items.map((item, index) => { + const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate const lineTotal = item.quantity * item.unit_price - vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 - } - } - const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount - - // Determine if this is a mixed-rate invoice - const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate)) - const isMixedRate = uniqueRates.size > 1 - - // Handle currency conversion - let exchangeRate: number | null = null - let exchangeRateDate: string | null = null - let subtotalSek: number | null = null - let vatAmountSek: number | null = null - let totalSek: number | null = null - - if (invoiceInput.currency !== 'SEK') { - const rateData = await fetchExchangeRate(invoiceInput.currency) - if (rateData) { - exchangeRate = rateData.rate - exchangeRateDate = rateData.date - subtotalSek = convertToSEK(subtotal, exchangeRate) - vatAmountSek = convertToSEK(vatAmount, exchangeRate) - totalSek = convertToSEK(total, exchangeRate) - } - } - - // Generate document number — eagerly for delivery notes (separate sequence, - // separate UX), lazily for invoices and proformas (assigned at first send so - // discarded drafts never consume a number). - let invoiceNumber: string | null = null - if (documentType === 'delivery_note') { - const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', { - p_company_id: companyId, + const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100 + return { + invoice_id: invoice.id, + sort_order: index, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: lineTotal, + vat_rate: itemRate, + vat_amount: itemVat, + } }) - invoiceNumber = dnNumber - } - // Create invoice - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .insert({ - user_id: user.id, - company_id: companyId, - customer_id: invoiceInput.customer_id, - invoice_number: invoiceNumber, - invoice_date: invoiceInput.invoice_date, - due_date: invoiceInput.due_date, - delivery_date: invoiceInput.delivery_date ?? null, - currency: invoiceInput.currency, - exchange_rate: exchangeRate, - exchange_rate_date: exchangeRateDate, - subtotal: documentType === 'delivery_note' ? 0 : subtotal, - subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, - vat_amount: vatAmount, - vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, - total, - total_sek: documentType === 'delivery_note' ? null : totalSek, - vat_treatment: vatRules.treatment, - vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)), - moms_ruta: vatRules.momsRuta, - reverse_charge_text: vatRules.reverseChargeText || null, - your_reference: invoiceInput.your_reference, - our_reference: invoiceInput.our_reference, - notes: invoiceInput.notes, - document_type: documentType, - }) - .select() - .single() + const { error: itemsError } = await supabase.from('invoice_items').insert(items) - if (invoiceError) { - console.error('Invoice insert error:', invoiceError) - return NextResponse.json({ error: invoiceError.message }, { status: 500 }) - } - - // Create invoice items with per-line VAT - const items = invoiceInput.items.map((item, index) => { - const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - const lineTotal = item.quantity * item.unit_price - const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100 - return { - invoice_id: invoice.id, - sort_order: index, - description: item.description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - line_total: lineTotal, - vat_rate: itemRate, - vat_amount: itemVat, + if (itemsError) { + // Roll back invoice insert; otherwise the row is orphaned. + await supabase.from('invoices').delete().eq('id', invoice.id) + log.error('invoice items insert failed; rolled back invoice', itemsError, { + invoiceId: invoice.id, + }) + return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { + requestId, + details: { pgCode: itemsError.code, pgMessage: itemsError.message }, + }) } - }) - const { error: itemsError } = await supabase - .from('invoice_items') - .insert(items) + const { data: completeInvoice } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', invoice.id) + .single() - if (itemsError) { - // Rollback invoice creation - await supabase.from('invoices').delete().eq('id', invoice.id) - return NextResponse.json({ error: itemsError.message }, { status: 500 }) - } + // Emit event only for real invoices (proformas / delivery notes are informational). + if (completeInvoice && documentType === 'invoice') { + await eventBus.emit({ + type: 'invoice.created', + payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id }, + }) + } - // Fetch complete invoice with items - const { data: completeInvoice } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', invoice.id) - .single() + return NextResponse.json({ data: completeInvoice }) + }, + { requireWrite: true }, +) - // Emit event only for real invoices (proformas and delivery notes are informational) - if (completeInvoice && documentType === 'invoice') { - await eventBus.emit({ - type: 'invoice.created', - payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id }, - }) - } - - return NextResponse.json({ data: completeInvoice }) -} - -// Create a credit note for an existing invoice async function createCreditNote( - supabase: Awaited>, + supabase: SupabaseClient, companyId: string, userId: string, - input: { credited_invoice_id: string; reason?: string } + input: { credited_invoice_id: string; reason?: string }, + log: Logger, + requestId: string, ) { - // Fetch the original invoice with items const { data: originalInvoice, error: originalError } = await supabase .from('invoices') .select('*, items:invoice_items(*)') @@ -276,34 +267,29 @@ async function createCreditNote( .single() if (originalError || !originalInvoice) { - return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 }) + return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId }) } - // Credit notes can only be created from real invoices if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') { - return NextResponse.json( - { error: 'Credit notes can only be created from standard invoices' }, - { status: 400 } - ) + return errorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', log, { + requestId, + details: { documentType: originalInvoice.document_type }, + }) } - // Check if invoice is already credited if (originalInvoice.status === 'credited') { - return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 }) + return errorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', log, { requestId }) } - // Check if invoice can be credited (only sent, paid, or overdue invoices can be credited) if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) { - return NextResponse.json( - { error: 'Only sent, paid, or overdue invoices can be credited' }, - { status: 400 } - ) + return errorResponseFromCode('INVOICE_CREDIT_NOT_SENT', log, { + requestId, + details: { currentStatus: originalInvoice.status }, + }) } - // Generate credit note number const creditNoteNumber = `KR-${originalInvoice.invoice_number}` - // Create the credit note with negated amounts const { data: creditNote, error: creditNoteError } = await supabase .from('invoices') .insert({ @@ -317,33 +303,33 @@ async function createCreditNote( currency: originalInvoice.currency, exchange_rate: originalInvoice.exchange_rate, exchange_rate_date: originalInvoice.exchange_rate_date, - // Negate all amounts subtotal: -Math.abs(originalInvoice.subtotal), subtotal_sek: originalInvoice.subtotal_sek ? -Math.abs(originalInvoice.subtotal_sek) : null, vat_amount: -Math.abs(originalInvoice.vat_amount), vat_amount_sek: originalInvoice.vat_amount_sek ? -Math.abs(originalInvoice.vat_amount_sek) : null, total: -Math.abs(originalInvoice.total), total_sek: originalInvoice.total_sek ? -Math.abs(originalInvoice.total_sek) : null, - // Same VAT treatment as original vat_treatment: originalInvoice.vat_treatment, vat_rate: originalInvoice.vat_rate, moms_ruta: originalInvoice.moms_ruta, reverse_charge_text: originalInvoice.reverse_charge_text, - // References your_reference: originalInvoice.your_reference, our_reference: originalInvoice.our_reference, notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`, credited_invoice_id: input.credited_invoice_id, - status: 'sent', // Credit notes are immediately "sent" + status: 'sent', }) .select() .single() if (creditNoteError) { - return NextResponse.json({ error: creditNoteError.message }, { status: 500 }) + log.error('credit note insert failed', creditNoteError) + return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, { + requestId, + details: { pgCode: creditNoteError.code, pgMessage: creditNoteError.message }, + }) } - // Create credit note items (negated from original, preserving per-line VAT) const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({ invoice_id: creditNote.id, sort_order: item.sort_order, @@ -356,30 +342,30 @@ async function createCreditNote( vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), })) - const { error: itemsError } = await supabase - .from('invoice_items') - .insert(creditNoteItems) + const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems) if (itemsError) { - // Rollback credit note creation await supabase.from('invoices').delete().eq('id', creditNote.id) - return NextResponse.json({ error: itemsError.message }, { status: 500 }) + log.error('credit note items insert failed; rolled back', itemsError, { + creditNoteId: creditNote.id, + }) + return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { + requestId, + details: { pgCode: itemsError.code, pgMessage: itemsError.message }, + }) } - // Update original invoice status to 'credited' await supabase .from('invoices') .update({ status: 'credited' }) .eq('id', input.credited_invoice_id) - // Fetch complete credit note with items const { data: completeCreditNote } = await supabase .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', creditNote.id) .single() - // Fetch entity type and accounting method for correct account mapping const { data: creditNoteSettings } = await supabase .from('company_settings') .select('entity_type, accounting_method') @@ -389,8 +375,8 @@ async function createCreditNote( const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma' const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual' - // Create journal entry for the credit note (non-blocking) - // Cash method: skip — no original invoice entry exists to reverse; deferred until refund + // Cash method skips: there's no original invoice JE to reverse — recognition + // is deferred until refund. if (completeCreditNote && accountingMethod === 'accrual') { try { const journalEntry = await createCreditNoteJournalEntry( @@ -399,7 +385,7 @@ async function createCreditNote( userId, completeCreditNote as Invoice, entityType, - completeCreditNote.customer?.name + completeCreditNote.customer?.name, ) if (journalEntry) { await supabase @@ -408,7 +394,10 @@ async function createCreditNote( .eq('id', creditNote.id) } } catch (err) { - console.error('Failed to create credit note journal entry:', err) + log.error('failed to create credit note journal entry', err as Error, { + creditNoteId: creditNote.id, + }) + // Non-blocking — credit note still exists. } await eventBus.emit({ diff --git a/app/api/reports/balance-sheet/route.ts b/app/api/reports/balance-sheet/route.ts index b7bdf70a..7fe5f69f 100644 --- a/app/api/reports/balance-sheet/route.ts +++ b/app/api/reports/balance-sheet/route.ts @@ -1,47 +1,46 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.balance_sheet', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') - - if (!periodId) { - return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) - } - - const { data: period } = await supabase - .from('fiscal_periods') - .select('period_start, period_end') - .eq('id', periodId) - .eq('company_id', companyId) - .single() - - try { - const result = await generateBalanceSheet(supabase, companyId, periodId) - - if (period) { - result.period = { - start: period.period_start, - end: period.period_end, - } + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) } - return NextResponse.json({ data: result }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate balance sheet' }, - { status: 500 } - ) - } -} + const opLog = log.child({ periodId }) + + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single() + + try { + const result = await generateBalanceSheet(supabase, companyId!, periodId) + + if (period) { + result.period = { + start: period.period_start, + end: period.period_end, + } + } + + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('balance sheet generation failed', err as Error) + return errorResponseFromCode('REPORT_GENERATION_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index e48e9d39..d169b599 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -1,29 +1,31 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'report.general_ledger', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const accountFrom = searchParams.get('account_from') || undefined + const accountTo = searchParams.get('account_to') || undefined - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') - const accountFrom = searchParams.get('account_from') || undefined - const accountTo = searchParams.get('account_to') || undefined - - if (!periodId) { - return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) - } - - const data = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo) - - return NextResponse.json({ data }) -} + try { + const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo) + return NextResponse.json({ data }) + } catch (err) { + log.error('general ledger generation failed', err as Error, { periodId }) + return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/income-statement/route.ts b/app/api/reports/income-statement/route.ts index ed15db2f..7f766853 100644 --- a/app/api/reports/income-statement/route.ts +++ b/app/api/reports/income-statement/route.ts @@ -1,48 +1,46 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.income_statement', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') - - if (!periodId) { - return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) - } - - // Get period dates - const { data: period } = await supabase - .from('fiscal_periods') - .select('period_start, period_end') - .eq('id', periodId) - .eq('company_id', companyId) - .single() - - try { - const result = await generateIncomeStatement(supabase, companyId, periodId) - - if (period) { - result.period = { - start: period.period_start, - end: period.period_end, - } + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) } - return NextResponse.json({ data: result }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate income statement' }, - { status: 500 } - ) - } -} + const opLog = log.child({ periodId }) + + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single() + + try { + const result = await generateIncomeStatement(supabase, companyId!, periodId) + + if (period) { + result.period = { + start: period.period_start, + end: period.period_end, + } + } + + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('income statement generation failed', err as Error) + return errorResponseFromCode('REPORT_GENERATION_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/ink2/route.ts b/app/api/reports/ink2/route.ts index a9a76139..08556d29 100644 --- a/app/api/reports/ink2/route.ts +++ b/app/api/reports/ink2/route.ts @@ -1,89 +1,79 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateINK2Declaration } from '@/lib/reports/ink2/ink2-engine' import { generateSRUSubmission, getZipFilename, } from '@/lib/reports/ink2/sru-generator' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import JSZip from 'jszip' /** * GET /api/reports/ink2 * - * Generate INK2 declaration for aktiebolag. - * * Query parameters: - * - period_id: Fiscal period ID (required) - * - format: 'json' (default) or 'sru' for SRU file download (ZIP with INFO.SRU + BLANKETTER.SRU) + * period_id: fiscal period id (required) + * format: 'json' (default) or 'sru' for SRU file download (ZIP with INFO.SRU + BLANKETTER.SRU) */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.ink2', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const format = searchParams.get('format') || 'json' - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') - const format = searchParams.get('format') || 'json' - - if (!periodId) { - return NextResponse.json( - { error: 'period_id is required' }, - { status: 400 } - ) - } - - try { - const declaration = await generateINK2Declaration(supabase, companyId, periodId) - - if (format === 'sru') { - const submission = generateSRUSubmission(declaration) - - // Encode both files as ISO 8859-1 (Latin-1) — required by Skatteverket - const infoBytes = encodeISO88591(submission.infoSru) - const blanketterBytes = encodeISO88591(submission.blanketterSru) - - // Create ZIP with both files - const zip = new JSZip() - zip.file('INFO.SRU', infoBytes) - zip.file('BLANKETTER.SRU', blanketterBytes) - - const zipArrayBuffer = await zip.generateAsync({ type: 'arraybuffer' }) - const filename = getZipFilename(declaration) - - return new NextResponse(zipArrayBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }) + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) } - return NextResponse.json({ data: declaration }) - } catch (err) { - console.error('Error generating INK2 declaration:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate INK2 declaration' }, - { status: 500 } - ) - } -} + const opLog = log.child({ periodId, format }) -/** - * Encode a string as ISO 8859-1 (Latin-1) bytes. - * Characters outside the Latin-1 range are replaced with '?'. - */ + try { + const declaration = await generateINK2Declaration(supabase, companyId!, periodId) + + if (format === 'sru') { + const submission = generateSRUSubmission(declaration) + + // Skatteverket requires ISO 8859-1 (Latin-1) + const infoBytes = encodeISO88591(submission.infoSru) + const blanketterBytes = encodeISO88591(submission.blanketterSru) + + const zip = new JSZip() + zip.file('INFO.SRU', infoBytes) + zip.file('BLANKETTER.SRU', blanketterBytes) + + const zipArrayBuffer = await zip.generateAsync({ type: 'arraybuffer' }) + const filename = getZipFilename(declaration) + + return new NextResponse(zipArrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'X-Request-Id': requestId, + }, + }) + } + + return NextResponse.json({ data: declaration }) + } catch (err) { + opLog.error('ink2 declaration generation failed', err as Error) + return errorResponseFromCode('TAX_DECL_GENERATION_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) + +/** Encode a string as ISO 8859-1 bytes; characters outside Latin-1 become '?'. */ function encodeISO88591(str: string): Uint8Array { const bytes = new Uint8Array(str.length) for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i) - bytes[i] = code <= 0xFF ? code : 0x3F // '?' for unmappable chars + bytes[i] = code <= 0xFF ? code : 0x3F } return bytes } diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts index a8cbc870..15331b33 100644 --- a/app/api/reports/ne-bilaga/route.ts +++ b/app/api/reports/ne-bilaga/route.ts @@ -1,4 +1,3 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateNEDeclaration } from '@/lib/reports/ne-bilaga/ne-engine' import { @@ -6,67 +5,56 @@ import { sruFileToString, getSRUFilename, } from '@/lib/reports/ne-bilaga/sru-generator' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' /** * GET /api/reports/ne-bilaga * - * Generate NE declaration (NE-bilaga) for enskild firma. - * * Query parameters: - * - period_id: Fiscal period ID (required) - * - format: 'json' (default) or 'sru' for SRU file download - * - * Returns: - * - JSON: NE declaration with rutor R1-R11 and breakdown - * - SRU: Downloadable SRU file for Skatteverket submission + * period_id: fiscal period id (required) + * format: 'json' (default) or 'sru' for SRU file download */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.ne_bilaga', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const format = searchParams.get('format') || 'json' - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') - const format = searchParams.get('format') || 'json' - - if (!periodId) { - return NextResponse.json( - { error: 'period_id is required' }, - { status: 400 } - ) - } - - try { - const declaration = await generateNEDeclaration(supabase, companyId, periodId) - - if (format === 'sru') { - // Generate and return SRU file - const sruFile = generateSRUFile(declaration) - const sruContent = sruFileToString(sruFile) - const filename = getSRUFilename(declaration) - - return new NextResponse(sruContent, { - status: 200, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, - }) + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) } - // Default: return JSON - return NextResponse.json({ data: declaration }) - } catch (err) { - console.error('Error generating NE declaration:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate NE declaration' }, - { status: 500 } - ) - } -} + const opLog = log.child({ periodId, format }) + + try { + const declaration = await generateNEDeclaration(supabase, companyId!, periodId) + + if (format === 'sru') { + const sruFile = generateSRUFile(declaration) + const sruContent = sruFileToString(sruFile) + const filename = getSRUFilename(declaration) + + return new NextResponse(sruContent, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'X-Request-Id': requestId, + }, + }) + } + + return NextResponse.json({ data: declaration }) + } catch (err) { + opLog.error('ne-bilaga declaration generation failed', err as Error) + return errorResponseFromCode('TAX_DECL_GENERATION_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts index 06d3dcc6..be9b8e9f 100644 --- a/app/api/reports/sie-export/route.ts +++ b/app/api/reports/sie-export/route.ts @@ -1,55 +1,53 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateSIEExport } from '@/lib/reports/sie-export' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.sie_export', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') - const companyId = await requireCompanyId(supabase, user.id) + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) + } - const { searchParams } = new URL(request.url) - const periodId = searchParams.get('period_id') + const opLog = log.child({ periodId }) - if (!periodId) { - return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) - } + const { data: company } = await supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .single() - // Get company settings for SIE metadata - const { data: company } = await supabase - .from('company_settings') - .select('company_name, org_number') - .eq('company_id', companyId) - .single() + if (!company) { + return errorResponseFromCode('SIE_EXPORT_COMPANY_NOT_FOUND', opLog, { requestId }) + } - if (!company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) - } + try { + const sieContent = await generateSIEExport(supabase, companyId!, { + fiscal_period_id: periodId, + company_name: company.company_name || 'Unknown', + org_number: company.org_number, + }) - try { - const sieContent = await generateSIEExport(supabase, companyId, { - fiscal_period_id: periodId, - company_name: company.company_name || 'Unknown', - org_number: company.org_number, - }) - - // Return as downloadable file - return new NextResponse(sieContent, { - status: 200, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Disposition': `attachment; filename="export_${periodId}.se"`, - }, - }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to generate SIE export' }, - { status: 500 } - ) - } -} + return new NextResponse(sieContent, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': `attachment; filename="export_${periodId}.se"`, + 'X-Request-Id': requestId, + }, + }) + } catch (err) { + opLog.error('sie export generation failed', err as Error) + return errorResponseFromCode('SIE_EXPORT_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts index c4eb850a..97ed1161 100644 --- a/app/api/reports/vat-declaration/route.ts +++ b/app/api/reports/vat-declaration/route.ts @@ -1,128 +1,106 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { calculateVatDeclaration, formatPeriodLabel, } from '@/lib/reports/vat-declaration' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { VatPeriodType, AccountingMethod } from '@/types' /** * GET /api/reports/vat-declaration * - * Calculate VAT declaration (momsdeklaration) for a given period. - * * Query parameters: - * - periodType: 'monthly' | 'quarterly' | 'yearly' - * - year: number (e.g., 2025) - * - period: number (1-12 for monthly, 1-4 for quarterly, 1 for yearly) - * - * Returns: - * - VAT rutor (boxes) according to Swedish tax authority format - * - Period information - * - Breakdown by source (invoices, transactions, receipts) + * periodType: 'monthly' | 'quarterly' | 'yearly' + * year: number (e.g., 2025) + * period: number (1-12 for monthly, 1-4 for quarterly, 1 for yearly) */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'report.vat_declaration', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { searchParams } = new URL(request.url) + const periodType = searchParams.get('periodType') as VatPeriodType | null + const yearStr = searchParams.get('year') + const periodStr = searchParams.get('period') - const companyId = await requireCompanyId(supabase, user.id) + if (!periodType || !yearStr || !periodStr) { + return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId }) + } - const { searchParams } = new URL(request.url) - const periodType = searchParams.get('periodType') as VatPeriodType | null - const yearStr = searchParams.get('year') - const periodStr = searchParams.get('period') + if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { + return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD_TYPE', log, { + requestId, + details: { received: periodType }, + }) + } - // Validate required parameters - if (!periodType || !yearStr || !periodStr) { - return NextResponse.json( - { error: 'Missing required parameters: periodType, year, period' }, - { status: 400 } - ) - } + const year = parseInt(yearStr, 10) + const period = parseInt(periodStr, 10) - // Validate periodType - if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { - return NextResponse.json( - { error: 'Invalid periodType. Must be: monthly, quarterly, or yearly' }, - { status: 400 } - ) - } + if (isNaN(year) || year < 2000 || year > 2100) { + return errorResponseFromCode('VAT_REPORT_INVALID_YEAR', log, { + requestId, + details: { received: yearStr }, + }) + } - const year = parseInt(yearStr, 10) - const period = parseInt(periodStr, 10) + if (isNaN(period)) { + return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, { + requestId, + details: { received: periodStr }, + }) + } - // Validate year - if (isNaN(year) || year < 2000 || year > 2100) { - return NextResponse.json( - { error: 'Invalid year. Must be between 2000 and 2100' }, - { status: 400 } - ) - } + if (periodType === 'monthly' && (period < 1 || period > 12)) { + return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, { + requestId, + details: { periodType, received: period, allowed: '1-12' }, + }) + } + if (periodType === 'quarterly' && (period < 1 || period > 4)) { + return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, { + requestId, + details: { periodType, received: period, allowed: '1-4' }, + }) + } + if (periodType === 'yearly' && period !== 1) { + return errorResponseFromCode('VAT_REPORT_INVALID_PERIOD', log, { + requestId, + details: { periodType, received: period, allowed: '1' }, + }) + } - // Validate period based on type - if (isNaN(period)) { - return NextResponse.json( - { error: 'Invalid period' }, - { status: 400 } - ) - } + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() - if (periodType === 'monthly' && (period < 1 || period > 12)) { - return NextResponse.json( - { error: 'Invalid period for monthly. Must be 1-12' }, - { status: 400 } - ) - } + const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' - if (periodType === 'quarterly' && (period < 1 || period > 4)) { - return NextResponse.json( - { error: 'Invalid period for quarterly. Must be 1-4' }, - { status: 400 } - ) - } + try { + const declaration = await calculateVatDeclaration( + supabase, companyId!, periodType, year, period, accountingMethod, + ) - if (periodType === 'yearly' && period !== 1) { - return NextResponse.json( - { error: 'Invalid period for yearly. Must be 1' }, - { status: 400 } - ) - } - - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' - - try { - const declaration = await calculateVatDeclaration( - supabase, - companyId, - periodType, - year, - period, - accountingMethod - ) - - return NextResponse.json({ - data: { - ...declaration, - periodLabel: formatPeriodLabel(periodType, year, period), - }, - }) - } catch (err) { - console.error('Error calculating VAT declaration:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to calculate VAT declaration' }, - { status: 500 } - ) - } -} + return NextResponse.json({ + data: { + ...declaration, + periodLabel: formatPeriodLabel(periodType, year, period), + }, + }) + } catch (err) { + log.error('vat declaration calculation failed', err as Error, { + periodType, + year, + period, + }) + return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts index 76047912..9f13ac9a 100644 --- a/app/api/salary/runs/[id]/book/route.ts +++ b/app/api/salary/runs/[id]/book/route.ts @@ -1,129 +1,126 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { createSalaryRunEntries } from '@/lib/salary/salary-entries' import { eventBus } from '@/lib/events' -import { createLogger } from '@/lib/logger' - -const log = createLogger('salary-book-route') +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' ensureInitialized() /** paid → booked (creates immutable journal entries) */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'salary_run.book', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ salaryRunId: id }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Verify run is paid - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'paid') - .single() - - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörningen måste vara markerad som betald' }, { status: 400 }) - } - - // Load employees with line items - const { data: employees, error: empError } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (empError || !employees || employees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - try { - const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries( - supabase, - companyId, - user.id, - { - id: run.id, - period_year: run.period_year, - period_month: run.period_month, - payment_date: run.payment_date, - voucher_series: run.voucher_series, - total_gross: run.total_gross, - total_tax: run.total_tax, - total_net: run.total_net, - total_avgifter: run.total_avgifter, - total_vacation_accrual: run.total_vacation_accrual, - employees: employees.map(sre => ({ - employee_id: sre.employee_id, - employment_type: sre.employee?.employment_type || 'employee', - gross_salary: sre.gross_salary, - tax_withheld: sre.tax_withheld, - net_salary: sre.net_salary, - avgifter_amount: sre.avgifter_amount, - avgifter_rate: sre.avgifter_rate, - vacation_accrual: sre.vacation_accrual, - vacation_accrual_avgifter: sre.vacation_accrual_avgifter, - line_items: (sre.line_items || []).map((li: Record) => ({ - item_type: li.item_type as string, - amount: li.amount as number, - account_number: li.account_number as string | null, - is_net_deduction: li.is_net_deduction as boolean, - is_gross_deduction: li.is_gross_deduction as boolean, - })), - })), - } - ) - - // Update run with journal entry references - const entryIds = [salaryEntry.id, avgifterEntry.id] - const updates: Record = { - status: 'booked', - salary_entry_id: salaryEntry.id, - avgifter_entry_id: avgifterEntry.id, - booked_at: new Date().toISOString(), - booked_by: user.id, - } - if (vacationEntry) { - updates.vacation_entry_id = vacationEntry.id - entryIds.push(vacationEntry.id) - } - if (pensionEntry) { - updates.pension_entry_id = pensionEntry.id - entryIds.push(pensionEntry.id) - } - - const { data: bookedRun, error: updateError } = await supabase + const { data: run, error: runError } = await supabase .from('salary_runs') - .update(updates) + .select('*') .eq('id', id) - .select() + .eq('company_id', companyId) + .eq('status', 'paid') .single() - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) + if (runError || !run) { + return errorResponseFromCode('SALARY_RUN_NOT_CALCULATED', opLog, { + requestId, + details: { reason: 'must_be_paid_status' }, + }) } - await eventBus.emit({ - type: 'salary_run.booked', - payload: { salaryRunId: id, entryIds, userId: user.id, companyId }, - }) + const { data: employees, error: empError } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) - return NextResponse.json({ data: bookedRun }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Bokföring misslyckades' - log.error(`Booking failed for salary run ${id}: ${message}`, err instanceof Error ? err.stack : err) - return NextResponse.json({ error: message }, { status: 500 }) - } -} + if (empError || !employees || employees.length === 0) { + return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId }) + } + + try { + const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries( + supabase, + companyId!, + user.id, + { + id: run.id, + period_year: run.period_year, + period_month: run.period_month, + payment_date: run.payment_date, + voucher_series: run.voucher_series, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_net: run.total_net, + total_avgifter: run.total_avgifter, + total_vacation_accrual: run.total_vacation_accrual, + employees: employees.map((sre) => ({ + employee_id: sre.employee_id, + employment_type: sre.employee?.employment_type || 'employee', + gross_salary: sre.gross_salary, + tax_withheld: sre.tax_withheld, + net_salary: sre.net_salary, + avgifter_amount: sre.avgifter_amount, + avgifter_rate: sre.avgifter_rate, + vacation_accrual: sre.vacation_accrual, + vacation_accrual_avgifter: sre.vacation_accrual_avgifter, + line_items: (sre.line_items || []).map((li: Record) => ({ + item_type: li.item_type as string, + amount: li.amount as number, + account_number: li.account_number as string | null, + is_net_deduction: li.is_net_deduction as boolean, + is_gross_deduction: li.is_gross_deduction as boolean, + })), + })), + }, + ) + + const entryIds = [salaryEntry.id, avgifterEntry.id] + const updates: Record = { + status: 'booked', + salary_entry_id: salaryEntry.id, + avgifter_entry_id: avgifterEntry.id, + booked_at: new Date().toISOString(), + booked_by: user.id, + } + if (vacationEntry) { + updates.vacation_entry_id = vacationEntry.id + entryIds.push(vacationEntry.id) + } + if (pensionEntry) { + updates.pension_entry_id = pensionEntry.id + entryIds.push(pensionEntry.id) + } + + const { data: bookedRun, error: updateError } = await supabase + .from('salary_runs') + .update(updates) + .eq('id', id) + .select() + .single() + + if (updateError) { + return errorResponse(updateError, opLog, { requestId }) + } + + await eventBus.emit({ + type: 'salary_run.booked', + payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! }, + }) + + return NextResponse.json({ data: bookedRun }) + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('salary booking failed', err as Error) + return errorResponseFromCode('SALARY_RUN_BOOK_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index 6b27b699..1f90398d 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -1,13 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { calculateSalary } from '@/lib/salary/calculation-engine' import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config' import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables' import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items' import { getLineItemAccount } from '@/lib/salary/account-mapping' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SalaryLineItemType } from '@/types' const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [ @@ -20,19 +19,12 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [ ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext( + 'salary_run.calculate', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ salaryRunId: id }) // Verify run is draft const { data: run, error: runError } = await supabase @@ -43,10 +35,13 @@ export async function POST( .single() if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + return errorResponseFromCode('SALARY_RUN_NOT_FOUND', opLog, { requestId }) } if (run.status !== 'draft') { - return NextResponse.json({ error: 'Kan bara beräkna utkast' }, { status: 400 }) + return errorResponseFromCode('SALARY_RUN_CALCULATE_FAILED', opLog, { + requestId, + details: { currentStatus: run.status, reason: 'not_draft' }, + }) } const paymentYear = parseInt(run.payment_date.split('-')[0]) @@ -61,7 +56,7 @@ export async function POST( .eq('salary_run_id', id) if (empError || !runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId }) } // Pre-calculation validation — ensure employees have required data @@ -82,10 +77,10 @@ export async function POST( } } if (validationErrors.length > 0) { - return NextResponse.json({ - error: 'Valideringsfel — korrigera anställda innan beräkning', - details: validationErrors, - }, { status: 400 }) + return errorResponseFromCode('VALIDATION_ERROR', opLog, { + requestId, + details: { issues: validationErrors, reason: 'employee_data_incomplete' }, + }) } // Fetch tax table rates from Skatteverket API for all needed tables/columns @@ -104,7 +99,11 @@ export async function POST( taxTableSource = result.source } catch (err) { if (err instanceof TaxTableUnavailableError) { - return NextResponse.json({ error: err.message }, { status: 503 }) + return errorResponseFromCode('SALARY_RUN_TAX_TABLE_MISSING', opLog, { + requestId, + details: { reason: err.message, paymentYear, tableNumbers }, + status: 503, + }) } throw err } @@ -154,7 +153,7 @@ export async function POST( // in-memory lineItems array passed to calculateSalary. const absenceResult = await loadAndDeriveAbsence({ supabase, - companyId, + companyId: companyId!, employeeId: emp.id, monthlySalary: emp.monthly_salary || 0, payrollConfig: config, @@ -168,7 +167,7 @@ export async function POST( .eq('salary_run_employee_id', sre.id) .in('item_type', DERIVED_ABSENCE_TYPES) if (delAbsErr) { - return NextResponse.json({ error: delAbsErr.message }, { status: 500 }) + return errorResponse(delAbsErr, opLog, { requestId }) } if (absenceResult.lineItems.length > 0) { @@ -191,7 +190,7 @@ export async function POST( .from('salary_line_items') .insert(rows) if (insAbsErr) { - return NextResponse.json({ error: insAbsErr.message }, { status: 500 }) + return errorResponse(insAbsErr, opLog, { requestId }) } } @@ -300,7 +299,7 @@ export async function POST( .eq('id', sre.id) if (empUpdateError) { - return NextResponse.json({ error: empUpdateError.message }, { status: 500 }) + return errorResponse(empUpdateError, opLog, { requestId }) } totalGross += result.grossSalary @@ -328,7 +327,7 @@ export async function POST( .single() if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) + return errorResponse(updateError, opLog, { requestId }) } const warnings: string[] = [] @@ -343,4 +342,6 @@ export async function POST( } return NextResponse.json({ data: updatedRun, warnings }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/route.ts b/app/api/salary/runs/route.ts index 71473e8f..ecf7937f 100644 --- a/app/api/salary/runs/route.ts +++ b/app/api/salary/runs/route.ts @@ -1,97 +1,109 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSalaryRunSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { eventBus } from '@/lib/events' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() -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 }) +export const GET = withRouteContext( + 'salary_run.list', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) + const year = searchParams.get('year') - const { searchParams } = new URL(request.url) - const year = searchParams.get('year') + let query = supabase + .from('salary_runs') + .select('*') + .eq('company_id', companyId) - let query = supabase - .from('salary_runs') - .select('*') - .eq('company_id', companyId) + if (year) { + query = query.eq('period_year', parseInt(year)) + } - if (year) { - query = query.eq('period_year', parseInt(year)) - } + const { data, error } = await query + .order('period_year', { ascending: false }) + .order('period_month', { ascending: false }) - const { data, error } = await query.order('period_year', { ascending: false }).order('period_month', { ascending: false }) + if (error) { + log.error('salary run list failed', error) + return errorResponse(error, log, { requestId }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + return NextResponse.json({ data }) + }, +) - return NextResponse.json({ data }) -} +export const POST = withRouteContext( + 'salary_run.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, CreateSalaryRunSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Check for existing run - const { data: existing } = await supabase - .from('salary_runs') - .select('id') - .eq('company_id', companyId) - .eq('period_year', body.period_year) - .eq('period_month', body.period_month) - .single() - - if (existing) { - return NextResponse.json({ error: 'Det finns redan en lönekörning för denna period' }, { status: 409 }) - } - - const { data: run, error } = await supabase - .from('salary_runs') - .insert({ - company_id: companyId, - user_id: user.id, - period_year: body.period_year, - period_month: body.period_month, - payment_date: body.payment_date, - voucher_series: body.voucher_series, - notes: body.notes || null, + const validation = await validateBody(request, CreateSalaryRunSchema, { + log, + operation: 'salary_run.create', }) - .select() - .single() + if (!validation.success) return validation.response + const body = validation.data - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + const { data: existing } = await supabase + .from('salary_runs') + .select('id') + .eq('company_id', companyId) + .eq('period_year', body.period_year) + .eq('period_month', body.period_month) + .single() - await eventBus.emit({ - type: 'salary_run.created', - payload: { - salaryRunId: run.id, - periodYear: body.period_year, - periodMonth: body.period_month, - userId: user.id, - companyId, - }, - }) + if (existing) { + return errorResponseFromCode('CONFLICT', log, { + requestId, + details: { + reason: 'salary_run_exists_for_period', + existingId: existing.id, + periodYear: body.period_year, + periodMonth: body.period_month, + }, + }) + } - return NextResponse.json({ data: run }, { status: 201 }) -} + const { data: run, error } = await supabase + .from('salary_runs') + .insert({ + company_id: companyId, + user_id: user.id, + period_year: body.period_year, + period_month: body.period_month, + payment_date: body.payment_date, + voucher_series: body.voucher_series, + notes: body.notes || null, + }) + .select() + .single() + + if (error) { + log.error('salary run insert failed', error) + return errorResponseFromCode('SALARY_RUN_CREATE_FAILED', log, { + requestId, + details: { reason: error.message }, + }) + } + + await eventBus.emit({ + type: 'salary_run.created', + payload: { + salaryRunId: run.id, + periodYear: body.period_year, + periodMonth: body.period_month, + userId: user.id, + companyId: companyId!, + }, + }) + + return NextResponse.json({ data: run }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/sandbox/cleanup/cron/route.ts b/app/api/sandbox/cleanup/cron/route.ts index 3a59ec40..a141fb87 100644 --- a/app/api/sandbox/cleanup/cron/route.ts +++ b/app/api/sandbox/cleanup/cron/route.ts @@ -1,44 +1,36 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' /** - * GET /api/sandbox/cleanup/cron - * Daily cron job to clean up expired sandbox users (>24h old). - * Runs at 04:00 UTC every day. + * GET /api/sandbox/cleanup/cron — daily 04:00 UTC. + * Removes expired sandbox users (>24h old). */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - +export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) - try { - const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', { - p_max_age_hours: 24, - }) + const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', { + p_max_age_hours: 24, + }) - if (error) throw error - - const cleaned = data ?? 0 - console.log(`Sandbox cleanup cron completed: ${cleaned} users removed`) - - return NextResponse.json({ success: true, cleaned }) - } catch (error) { - console.error('Error in sandbox cleanup cron:', error) - return NextResponse.json( - { error: 'Failed to clean up sandbox users' }, - { status: 500 } - ) + if (error) { + ctx.log.error('sandbox cleanup rpc failed', error) + return errorResponse(error, ctx.log, { requestId: ctx.requestId }) } -} + + const cleaned = data ?? 0 + ctx.log.info('sandbox cleanup summary', { cleaned }) + + return NextResponse.json({ success: true, cleaned }) +}) diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index 25121bcf..a58e60cc 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,106 +1,103 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateApiKey, hashApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { generateApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { ApiKeyScope } from '@/lib/auth/api-keys' -/** - * GET /api/settings/api-keys — List user's API keys (never exposes the key itself) - */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +/** GET /api/settings/api-keys — list the company's API keys (key value never returned). */ +export const GET = withRouteContext( + 'api_key.list', + async (_request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase + .from('api_keys') + .select('id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at') + .eq('company_id', companyId) + .order('created_at', { ascending: false }) - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + log.error('api_keys list failed', error) + return errorResponse(error, log, { requestId }) + } - const { data, error } = await supabase - .from('api_keys') - .select('id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at') - .eq('company_id', companyId) - .order('created_at', { ascending: false }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** - * POST /api/settings/api-keys — Create a new API key - * Returns the full key ONCE. After this, only the prefix is available. + * POST /api/settings/api-keys — create a new API key. + * + * Returns the full key exactly once; after this the prefix is the only + * stored representation. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'api_key.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - let name = 'Unnamed key' - let scopes: ApiKeyScope[] = DEFAULT_SCOPES - try { - const body = await request.json() - if (body.name && typeof body.name === 'string') { - name = body.name.slice(0, 100) + let name = 'Unnamed key' + let scopes: ApiKeyScope[] = DEFAULT_SCOPES + try { + const body = await request.json() + if (body.name && typeof body.name === 'string') { + name = body.name.slice(0, 100) + } + const parsed = validateScopes(body.scopes) + if (parsed) { + scopes = parsed + } else if (body.scopes !== undefined) { + return errorResponseFromCode('API_KEY_SCOPE_INVALID', log, { + requestId, + details: { received: body.scopes }, + }) + } + } catch { + // Empty body — use defaults. } - const parsed = validateScopes(body.scopes) - if (parsed) { - scopes = parsed + + const { count } = await supabase + .from('api_keys') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('revoked_at', null) + + if (count !== null && count >= 10) { + return errorResponseFromCode('API_KEY_QUOTA_EXCEEDED', log, { + requestId, + details: { activeCount: count, limit: 10 }, + }) } - } catch { - // Empty body is fine, use defaults - } - // Limit to 10 active keys per company - const { count } = await supabase - .from('api_keys') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .is('revoked_at', null) + const { key, hash, prefix } = generateApiKey() - if (count !== null && count >= 10) { - return NextResponse.json( - { error: 'Maximum 10 active API keys allowed' }, - { status: 400 } - ) - } + const { data, error } = await supabase + .from('api_keys') + .insert({ + user_id: user.id, + company_id: companyId, + key_hash: hash, + key_prefix: prefix, + name, + scopes, + }) + .select('id, key_prefix, name, scopes, created_at') + .single() - const { key, hash, prefix } = generateApiKey() + if (error) { + log.error('api_key insert failed', error) + return errorResponseFromCode('API_KEY_CREATE_FAILED', log, { + requestId, + details: { reason: error.message }, + }) + } - const { data, error } = await supabase - .from('api_keys') - .insert({ - user_id: user.id, - company_id: companyId, - key_hash: hash, - key_prefix: prefix, - name, - scopes, + return NextResponse.json({ + data: { + ...data, + key, // only time the full key is returned + }, }) - .select('id, key_prefix, name, scopes, created_at') - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Return the full key exactly once - return NextResponse.json({ - data: { - ...data, - key, // Only time the full key is returned - }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts index 1be6d075..bb4b63db 100644 --- a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts @@ -58,7 +58,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Not found') + expect((body.error as unknown as { code: string }).code).toBe('SI_NOT_FOUND') }) it('returns 400 when invoice is not in registered status', async () => { @@ -69,7 +69,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Kan bara godkänna registrerade fakturor') + expect((body.error as unknown as { code: string }).code).toBe('SI_APPROVE_NOT_REGISTERED') }) it('approves registered invoice', async () => { diff --git a/app/api/supplier-invoices/[id]/approve/route.ts b/app/api/supplier-invoices/[id]/approve/route.ts index 06fd61e5..467382a1 100644 --- a/app/api/supplier-invoices/[id]/approve/route.ts +++ b/app/api/supplier-invoices/[id]/approve/route.ts @@ -1,69 +1,62 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SupplierInvoice } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext( + 'supplier_invoice.approve', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { data: invoice } = await supabase + .from('supplier_invoices') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!invoice) { + return errorResponseFromCode('SI_NOT_FOUND', log, { requestId }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (invoice.status !== 'registered') { + return errorResponseFromCode('SI_APPROVE_NOT_REGISTERED', log, { + requestId, + details: { currentStatus: invoice.status }, + }) + } - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('supplier_invoices') + .update({ status: 'approved' }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() - const { data: invoice } = await supabase - .from('supplier_invoices') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (error) { + log.error('supplier_invoice update to approved failed', error) + return errorResponseFromCode('SI_APPROVE_UPDATE_FAILED', log, { requestId }) + } - if (!invoice) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + // Event emission is non-blocking — the registration entry is created by + // the supplier-invoice handler bound to this event. If the handler throws, + // bus.ts persists an EventHandlerFailed row for traceability. + try { + await eventBus.emit({ + type: 'supplier_invoice.approved', + payload: { supplierInvoice: data as SupplierInvoice, companyId, userId: user.id }, + }) + } catch (err) { + log.warn('supplier_invoice.approved event emission failed', err as Error) + } - if (invoice.status !== 'registered') { - return NextResponse.json( - { error: 'Kan bara godkänna registrerade fakturor' }, - { status: 400 } - ) - } - - const { data, error } = await supabase - .from('supplier_invoices') - .update({ status: 'approved' }) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - try { - await eventBus.emit({ - type: 'supplier_invoice.approved', - payload: { supplierInvoice: data as SupplierInvoice, companyId, userId: user.id }, - }) - } catch { - // Non-blocking - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 2fa223a8..b5134302 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -1,181 +1,166 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext( + 'supplier_invoice.credit', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierInvoiceId: id }) - const { data: { user } } = await supabase.auth.getUser() + const { data: original, error: fetchError } = await supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch original invoice with supplier and items - const { data: original, error: fetchError } = await supabase - .from('supplier_invoices') - .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !original) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - if (original.status === 'credited') { - return NextResponse.json( - { error: 'Fakturan har redan krediterats' }, - { status: 400 } - ) - } - - // Get next arrival number - const { data: arrivalNum } = await supabase - .rpc('get_next_arrival_number', { p_company_id: companyId }) - - // Create credit note invoice (negative amounts) - const { data: creditNote, error: creditError } = await supabase - .from('supplier_invoices') - .insert({ - user_id: user.id, - company_id: companyId, - supplier_id: original.supplier_id, - arrival_number: arrivalNum, - supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`, - invoice_date: new Date().toISOString().split('T')[0], - due_date: new Date().toISOString().split('T')[0], - status: 'registered', - currency: original.currency, - exchange_rate: original.exchange_rate, - vat_treatment: original.vat_treatment, - reverse_charge: original.reverse_charge, - subtotal: original.subtotal, - subtotal_sek: original.subtotal_sek, - vat_amount: original.vat_amount, - vat_amount_sek: original.vat_amount_sek, - total: original.total, - total_sek: original.total_sek, - remaining_amount: 0, - is_credit_note: true, - credited_invoice_id: id, - }) - .select() - .single() - - if (creditError || !creditNote) { - return NextResponse.json({ error: creditError?.message || 'Failed to create credit note' }, { status: 500 }) - } - - // Copy items to credit note - const creditItems = (original.items || []).map((item: SupplierInvoiceItem) => ({ - supplier_invoice_id: creditNote.id, - sort_order: item.sort_order, - description: item.description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - line_total: item.line_total, - account_number: item.account_number, - vat_code: item.vat_code, - vat_rate: item.vat_rate, - vat_amount: item.vat_amount, - })) - - await supabase.from('supplier_invoice_items').insert(creditItems) - - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' - - // Create credit note journal entry (accrual only) - // Cash method: skip — no original registration entry exists to reverse; deferred until refund - let journalEntryId: string | null = null - if (accountingMethod === 'accrual') { - try { - const journalEntry = await createSupplierCreditNoteEntry( - supabase, - companyId, - user.id, - creditNote as SupplierInvoice, - creditItems as SupplierInvoiceItem[], - original.supplier?.supplier_type || 'swedish_business', - original.supplier?.name - ) - if (journalEntry) { - journalEntryId = journalEntry.id - await supabase - .from('supplier_invoices') - .update({ registration_journal_entry_id: journalEntry.id }) - .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) - - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - 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 } - ) + if (fetchError || !original) { + return errorResponseFromCode('SI_NOT_FOUND', opLog, { requestId }) } - } - // Update original invoice: reduce remaining_amount - const newRemaining = Math.max(0, original.remaining_amount - original.total) - const newStatus = newRemaining <= 0 ? 'credited' : original.status + if (original.status === 'credited') { + return errorResponseFromCode('SI_CREDIT_ALREADY_CREDITED', opLog, { requestId }) + } - await supabase - .from('supplier_invoices') - .update({ - status: newStatus, - remaining_amount: newRemaining, + const { data: arrivalNum } = await supabase + .rpc('get_next_arrival_number', { p_company_id: companyId }) + + const { data: creditNote, error: creditError } = await supabase + .from('supplier_invoices') + .insert({ + user_id: user.id, + company_id: companyId, + supplier_id: original.supplier_id, + arrival_number: arrivalNum, + supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`, + invoice_date: new Date().toISOString().split('T')[0], + due_date: new Date().toISOString().split('T')[0], + status: 'registered', + currency: original.currency, + exchange_rate: original.exchange_rate, + vat_treatment: original.vat_treatment, + reverse_charge: original.reverse_charge, + subtotal: original.subtotal, + subtotal_sek: original.subtotal_sek, + vat_amount: original.vat_amount, + vat_amount_sek: original.vat_amount_sek, + total: original.total, + total_sek: original.total_sek, + remaining_amount: 0, + is_credit_note: true, + credited_invoice_id: id, + }) + .select() + .single() + + if (creditError || !creditNote) { + opLog.error('credit note insert failed', creditError as Error) + return errorResponseFromCode('SI_CREDIT_FAILED', opLog, { + requestId, + details: { reason: creditError?.message || 'unknown' }, + }) + } + + const creditItems = (original.items || []).map((item: SupplierInvoiceItem) => ({ + supplier_invoice_id: creditNote.id, + sort_order: item.sort_order, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: item.line_total, + account_number: item.account_number, + vat_code: item.vat_code, + vat_rate: item.vat_rate, + vat_amount: item.vat_amount, + })) + + await supabase.from('supplier_invoice_items').insert(creditItems) + + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() + + const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' + + // Cash method: skip — no original registration entry to reverse; + // recognition is deferred until refund. + let journalEntryId: string | null = null + if (accountingMethod === 'accrual') { + try { + const journalEntry = await createSupplierCreditNoteEntry( + supabase, companyId!, user.id, + creditNote as SupplierInvoice, + creditItems as SupplierInvoiceItem[], + original.supplier?.supplier_type || 'swedish_business', + original.supplier?.name, + ) + if (journalEntry) { + journalEntryId = journalEntry.id + await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: journalEntry.id }) + .eq('id', creditNote.id) + } + } catch (err) { + // Roll back the orphan credit-note row (items cascade-delete) on JE + // failure — same momsdeklaration-integrity concern as the POST route. + await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId) + + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('failed to create credit note journal entry', err as Error) + return errorResponseFromCode('SI_CREDIT_FAILED', opLog, { + requestId, + details: { + reason: err instanceof Error ? err.message : 'unknown', + step: 'credit_note_journal_entry', + }, + }) + } + } + + const newRemaining = Math.max(0, original.remaining_amount - original.total) + const newStatus = newRemaining <= 0 ? 'credited' : original.status + + await supabase + .from('supplier_invoices') + .update({ + status: newStatus, + remaining_amount: newRemaining, + }) + .eq('id', id) + + try { + await eventBus.emit({ + type: 'supplier_invoice.credited', + payload: { + supplierInvoice: original as SupplierInvoice, + creditNote: creditNote as SupplierInvoice, + companyId: companyId!, + userId: user.id, + }, + }) + } catch (err) { + opLog.warn('supplier_invoice.credited event emission failed', err as Error) + } + + return NextResponse.json({ + data: creditNote, + journal_entry_id: journalEntryId, }) - .eq('id', id) - - try { - await eventBus.emit({ - type: 'supplier_invoice.credited', - payload: { - supplierInvoice: original as SupplierInvoice, - creditNote: creditNote as SupplierInvoice, - companyId, - userId: user.id, - }, - }) - } catch { - // Non-blocking - } - - return NextResponse.json({ - data: creditNote, - journal_entry_id: journalEntryId, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index def06f6b..83dc6310 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -74,7 +74,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Not found') + expect((body.error as unknown as { code: string }).code).toBe('SI_NOT_FOUND') }) it('returns 400 when invoice is in wrong status', async () => { @@ -94,7 +94,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + expect((body.error as unknown as { code: string }).code).toBe('SI_PAID_NOT_PAYABLE') }) it('marks as fully paid with accrual method', async () => { @@ -261,7 +261,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Kunde inte bokföra betalningen') + expect((body.error as unknown as { code: string }).code).toBe('SI_PAID_FAILED') }) it('emits supplier_invoice.paid event', async () => { diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 4ff471c5..ba00943e 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -1,4 +1,3 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' @@ -6,197 +5,183 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext( + 'supplier_invoice.mark_paid', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierInvoiceId: id }) - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, MarkSupplierInvoicePaidSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Fetch invoice with supplier and items - const { data: invoice, error: fetchError } = await supabase - .from('supplier_invoices') - .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !invoice) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - if (!['registered', 'approved', 'partially_paid', 'overdue'].includes(invoice.status)) { - return NextResponse.json( - { error: 'Fakturan kan inte markeras som betald i nuvarande status' }, - { status: 400 } - ) - } - - const paymentDate = body.payment_date || new Date().toISOString().split('T')[0] - const paymentAmount = body.amount || invoice.remaining_amount - const now = new Date().toISOString() - - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - - // Create journal entry - let journalEntryId: string | null = null - - try { - if (accountingMethod === 'cash') { - const journalEntry = await createSupplierInvoiceCashEntry( - supabase, - companyId, - user.id, - invoice as SupplierInvoice, - (invoice.items || []) as SupplierInvoiceItem[], - paymentDate, - invoice.supplier?.supplier_type || 'swedish_business', - invoice.supplier?.name - ) - if (journalEntry) journalEntryId = journalEntry.id - } else { - const journalEntry = await createSupplierInvoicePaymentEntry( - supabase, - companyId, - user.id, - invoice as SupplierInvoice, - paymentAmount, - paymentDate, - body.exchange_rate_difference, - invoice.supplier?.name - ) - if (journalEntry) journalEntryId = journalEntry.id - } - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - console.error('Failed to create payment journal entry:', err) - return NextResponse.json( - { error: 'Kunde inte bokföra betalningen' }, - { status: 500 } - ) - } - - // Calculate new remaining amount - const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100 - const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 - const isFullyPaid = newRemaining <= 0 - const newStatus = isFullyPaid ? 'paid' : 'partially_paid' - - // Update invoice (CAS guard: only if status hasn't changed since we read it) - const { data: updateResult, error: updateError } = await supabase - .from('supplier_invoices') - .update({ - status: newStatus, - remaining_amount: Math.max(0, newRemaining), - paid_amount: newPaidAmount, - paid_at: isFullyPaid ? now : null, - payment_journal_entry_id: journalEntryId, + const validation = await validateBody(request, MarkSupplierInvoicePaidSchema, { + log: opLog, + operation: 'supplier_invoice.mark_paid', }) - .eq('id', id) - .eq('company_id', companyId) - .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) - .select('id') + if (!validation.success) return validation.response + const body = validation.data - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } + const { data: invoice, error: fetchError } = await supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() - // CAS guard: status changed between our read and write - if (!updateResult || updateResult.length === 0) { - if (journalEntryId) { - const { data: orphan } = await supabase - .from('journal_entries') - .select('fiscal_period_id, voucher_series, voucher_number') - .eq('id', journalEntryId) - .single() + if (fetchError || !invoice) { + return errorResponseFromCode('SI_NOT_FOUND', opLog, { requestId }) + } - await supabase - .from('journal_entries') - .update({ status: 'cancelled' }) - .eq('id', journalEntryId) + if (!['registered', 'approved', 'partially_paid', 'overdue'].includes(invoice.status)) { + return errorResponseFromCode('SI_PAID_NOT_PAYABLE', opLog, { + requestId, + details: { currentStatus: invoice.status }, + }) + } - if (orphan) { - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series || 'A', - gap_number: orphan.voucher_number, - explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - created_by: user.id, - }) + const paymentDate = body.payment_date || new Date().toISOString().split('T')[0] + const paymentAmount = body.amount || invoice.remaining_amount + const now = new Date().toISOString() + + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + + let journalEntryId: string | null = null + + try { + if (accountingMethod === 'cash') { + const journalEntry = await createSupplierInvoiceCashEntry( + supabase, companyId!, user.id, + invoice as SupplierInvoice, + (invoice.items || []) as SupplierInvoiceItem[], + paymentDate, + invoice.supplier?.supplier_type || 'swedish_business', + invoice.supplier?.name, + ) + if (journalEntry) journalEntryId = journalEntry.id + } else { + const journalEntry = await createSupplierInvoicePaymentEntry( + supabase, companyId!, user.id, + invoice as SupplierInvoice, + paymentAmount, paymentDate, + body.exchange_rate_difference, + invoice.supplier?.name, + ) + if (journalEntry) journalEntryId = journalEntry.id } + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('failed to create payment journal entry', err as Error) + return errorResponseFromCode('SI_PAID_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) } - return NextResponse.json( - { error: 'Fakturan har redan betalats av en annan förfrågan' }, - { status: 409 } - ) - } - // Record payment - const { error: paymentError } = await supabase - .from('supplier_invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - supplier_invoice_id: id, - payment_date: paymentDate, - amount: paymentAmount, - currency: invoice.currency, - exchange_rate_difference: body.exchange_rate_difference || 0, + const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100 + const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 + const isFullyPaid = newRemaining <= 0 + const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + + const { data: updateResult, error: updateError } = await supabase + .from('supplier_invoices') + .update({ + status: newStatus, + remaining_amount: Math.max(0, newRemaining), + paid_amount: newPaidAmount, + paid_at: isFullyPaid ? now : null, + payment_journal_entry_id: journalEntryId, + }) + .eq('id', id) + .eq('company_id', companyId) + .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + .select('id') + + if (updateError) { + opLog.error('supplier invoice update failed', updateError) + return errorResponse(updateError, opLog, { requestId }) + } + + if (!updateResult || updateResult.length === 0) { + // CAS guard: another request paid the invoice between our read and write. + // Cancel the orphaned JE and document the voucher gap. + if (journalEntryId) { + const { data: orphan } = await supabase + .from('journal_entries') + .select('fiscal_period_id, voucher_series, voucher_number') + .eq('id', journalEntryId) + .single() + + await supabase + .from('journal_entries') + .update({ status: 'cancelled' }) + .eq('id', journalEntryId) + + if (orphan) { + await supabase.from('voucher_gap_explanations').insert({ + company_id: companyId, + fiscal_period_id: orphan.fiscal_period_id, + voucher_series: orphan.voucher_series || 'A', + gap_number: orphan.voucher_number, + explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + created_by: user.id, + }) + } + } + return errorResponseFromCode('SI_PAID_ALREADY', opLog, { + requestId, + details: { reason: 'race' }, + }) + } + + const { error: paymentError } = await supabase + .from('supplier_invoice_payments') + .insert({ + user_id: user.id, + company_id: companyId, + supplier_invoice_id: id, + payment_date: paymentDate, + amount: paymentAmount, + currency: invoice.currency, + exchange_rate_difference: body.exchange_rate_difference || 0, + journal_entry_id: journalEntryId, + notes: body.notes || null, + }) + + if (paymentError) { + opLog.warn('failed to record supplier_invoice_payments row', paymentError) + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.paid', + payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, companyId: companyId!, userId: user.id }, + }) + } catch (err) { + opLog.warn('supplier_invoice.paid event emission failed', err as Error) + } + + return NextResponse.json({ + success: true, + status: newStatus, + paid_amount: newPaidAmount, + remaining_amount: Math.max(0, newRemaining), journal_entry_id: journalEntryId, - notes: body.notes || null, }) - - if (paymentError) { - console.error('Failed to record payment:', paymentError) - } - - try { - await eventBus.emit({ - type: 'supplier_invoice.paid', - payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, companyId, userId: user.id }, - }) - } catch { - // Non-blocking - } - - return NextResponse.json({ - success: true, - status: newStatus, - paid_amount: newPaidAmount, - remaining_amount: Math.max(0, newRemaining), - journal_entry_id: journalEntryId, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index f1690d1f..13384be2 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -105,7 +105,7 @@ describe('GET /api/supplier-invoices', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('DB error') + expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') }) }) @@ -153,7 +153,7 @@ describe('POST /api/supplier-invoices', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Supplier not found') + expect((body.error as unknown as { code: string }).code).toBe('SUPPLIER_NOT_FOUND') }) it('creates supplier invoice with items and arrival number', async () => { @@ -299,7 +299,7 @@ describe('POST /api/supplier-invoices', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Items insert failed') + expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED') }) it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => { @@ -342,15 +342,12 @@ describe('POST /api/supplier-invoices', () => { }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ - error: string - message: string - existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string } + error: { code: string; details: { existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string } } } }>(response) expect(status).toBe(409) - expect(body.error).toBe('duplicate_supplier_invoice_number') - expect(body.message).toMatch(/krediterad/i) - expect(body.existing).toEqual({ + expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER') + expect(body.error.details.existing).toEqual({ id: 'existing-1', supplier_invoice_number: 'LF-DUP', status: 'credited', @@ -392,15 +389,13 @@ describe('POST /api/supplier-invoices', () => { }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ - error: string - message: string - existing: { id: string; status: string; credit_note_id: string | null } + error: { code: string; details: { existing: { id: string; status: string; credit_note_id: string | null } } } }>(response) expect(status).toBe(409) - expect(body.error).toBe('duplicate_supplier_invoice_number') - expect(body.existing.status).toBe('approved') - expect(body.existing.credit_note_id).toBeNull() + expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER') + expect(body.error.details.existing.status).toBe('approved') + expect(body.error.details.existing.credit_note_id).toBeNull() }) it('returns generic 409 when existing row lookup races to nothing', async () => { @@ -430,11 +425,13 @@ describe('POST /api/supplier-invoices', () => { }, }) const response = await POST(request) - const { status, body } = await parseJsonResponse<{ error: string; message: string; existing?: unknown }>(response) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { existing?: unknown } } + }>(response) expect(status).toBe(409) - expect(body.error).toBe('duplicate_supplier_invoice_number') - expect(body.existing).toBeUndefined() + expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER') + expect(body.error.details?.existing).toBeNull() }) it('falls through to 500 for non-23505 insert errors', async () => { @@ -458,6 +455,6 @@ describe('POST /api/supplier-invoices', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('NOT NULL violation') + expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED') }) }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 1418be5c..2ace8701 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -1,301 +1,280 @@ -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 { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'supplier_invoice.list', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { searchParams } = new URL(request.url) + const status = searchParams.get('status') - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + let query = supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(id, name)') + .eq('company_id', companyId) - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') - - let query = supabase - .from('supplier_invoices') - .select('*, supplier:suppliers(id, name)') - .eq('company_id', companyId) - - if (status && status !== 'all') { - if (status === 'to_pay') { - query = query.in('status', ['approved', 'overdue']) - } else { - query = query.eq('status', status) - } - } - - const { data, error } = await query.order('due_date', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, CreateSupplierInvoiceSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Validate supplier exists and belongs to user - const { data: supplier, error: supplierError } = await supabase - .from('suppliers') - .select('*') - .eq('id', body.supplier_id) - .eq('company_id', companyId) - .single() - - if (supplierError || !supplier) { - return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) - } - - // Get next arrival number - const { data: arrivalNum, error: arrivalError } = await supabase - .rpc('get_next_arrival_number', { p_company_id: companyId }) - - if (arrivalError) { - return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 }) - } - - // Calculate totals from items (supports both amount-based and legacy quantity*price) - const items = body.items.map((item, index) => { - const vatRate = item.vat_rate ?? 0.25 - const lineTotal = item.amount != null - ? Math.round(item.amount * 100) / 100 - : Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100 - const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 - return { - sort_order: index, - description: item.description, - quantity: item.amount != null ? 1 : (item.quantity ?? 1), - unit: item.amount != null ? 'st' : (item.unit || 'st'), - unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0), - line_total: lineTotal, - account_number: item.account_number, - vat_code: item.vat_code || null, - vat_rate: vatRate, - vat_amount: vatAmount, - } - }) - - const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) - const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) - const total = Math.round((subtotal + vatAmount) * 100) / 100 - - const exchangeRate = body.exchange_rate || null - const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null - const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null - const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null - - // Insert supplier invoice - const { data: invoice, error: invoiceError } = await supabase - .from('supplier_invoices') - .insert({ - user_id: user.id, - company_id: companyId, - supplier_id: body.supplier_id, - arrival_number: arrivalNum, - supplier_invoice_number: body.supplier_invoice_number, - invoice_date: body.invoice_date, - due_date: body.due_date, - delivery_date: body.delivery_date || null, - status: 'registered', - currency: body.currency || 'SEK', - exchange_rate: exchangeRate, - vat_treatment: body.vat_treatment || 'standard_25', - reverse_charge: body.reverse_charge || false, - payment_reference: body.payment_reference || null, - subtotal: Math.round(subtotal * 100) / 100, - subtotal_sek: subtotalSek, - vat_amount: Math.round(vatAmount * 100) / 100, - vat_amount_sek: vatAmountSek, - total: Math.round(total * 100) / 100, - total_sek: totalSek, - remaining_amount: Math.round(total * 100) / 100, - notes: body.notes || null, - }) - .select() - .single() - - if (invoiceError || !invoice) { - // Translate the unique-index violation on (company_id, supplier_id, supplier_invoice_number) - // into a structured 409 so the UI can offer to undo the credit chain rather than - // leaving the user stuck on a generic 500. Other DB errors keep the existing 500 path. - const pgErr = invoiceError as { code?: string; message?: string } | null - const isDuplicateNumber = - pgErr?.code === '23505' && - (pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number') - - if (isDuplicateNumber) { - const { data: existing } = await supabase - .from('supplier_invoices') - .select('id, supplier_invoice_number, status') - .eq('company_id', companyId) - .eq('supplier_id', body.supplier_id) - .eq('supplier_invoice_number', body.supplier_invoice_number) - .maybeSingle() - - if (!existing) { - // Race: row vanished between the failing insert and our lookup. Stay defensive. - return NextResponse.json( - { - error: 'duplicate_supplier_invoice_number', - message: `Det finns redan en faktura med nummer ${body.supplier_invoice_number} från denna leverantör.`, - }, - { status: 409 } - ) + if (status && status !== 'all') { + if (status === 'to_pay') { + query = query.in('status', ['approved', 'overdue']) + } else { + query = query.eq('status', status) } + } - let creditNoteId: string | null = null - if (existing.status === 'credited') { - const { data: creditNote } = await supabase + const { data, error } = await query.order('due_date', { ascending: true }) + + if (error) { + log.error('supplier_invoice list failed', error) + return errorResponse(error, log, { requestId }) + } + + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext( + 'supplier_invoice.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, CreateSupplierInvoiceSchema, { + log, + operation: 'supplier_invoice.create', + }) + if (!validation.success) return validation.response + const body = validation.data + + const { data: supplier, error: supplierError } = await supabase + .from('suppliers') + .select('*') + .eq('id', body.supplier_id) + .eq('company_id', companyId) + .single() + + if (supplierError || !supplier) { + return errorResponseFromCode('SUPPLIER_NOT_FOUND', log, { requestId }) + } + + const { data: arrivalNum, error: arrivalError } = await supabase + .rpc('get_next_arrival_number', { p_company_id: companyId }) + + if (arrivalError) { + log.error('arrival number generation failed', arrivalError) + return errorResponseFromCode('SI_CREATE_FAILED', log, { + requestId, + details: { reason: arrivalError.message, step: 'arrival_number' }, + }) + } + + const items = body.items.map((item, index) => { + const vatRate = item.vat_rate ?? 0.25 + const lineTotal = item.amount != null + ? Math.round(item.amount * 100) / 100 + : Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100 + const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 + return { + sort_order: index, + description: item.description, + quantity: item.amount != null ? 1 : (item.quantity ?? 1), + unit: item.amount != null ? 'st' : (item.unit || 'st'), + unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0), + line_total: lineTotal, + account_number: item.account_number, + vat_code: item.vat_code || null, + vat_rate: vatRate, + vat_amount: vatAmount, + } + }) + + const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) + const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) + const total = Math.round((subtotal + vatAmount) * 100) / 100 + + const exchangeRate = body.exchange_rate || null + const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null + const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null + const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null + + const { data: invoice, error: invoiceError } = await supabase + .from('supplier_invoices') + .insert({ + user_id: user.id, + company_id: companyId, + supplier_id: body.supplier_id, + arrival_number: arrivalNum, + supplier_invoice_number: body.supplier_invoice_number, + invoice_date: body.invoice_date, + due_date: body.due_date, + delivery_date: body.delivery_date || null, + status: 'registered', + currency: body.currency || 'SEK', + exchange_rate: exchangeRate, + vat_treatment: body.vat_treatment || 'standard_25', + reverse_charge: body.reverse_charge || false, + payment_reference: body.payment_reference || null, + subtotal: Math.round(subtotal * 100) / 100, + subtotal_sek: subtotalSek, + vat_amount: Math.round(vatAmount * 100) / 100, + vat_amount_sek: vatAmountSek, + total: Math.round(total * 100) / 100, + total_sek: totalSek, + remaining_amount: Math.round(total * 100) / 100, + notes: body.notes || null, + }) + .select() + .single() + + if (invoiceError || !invoice) { + // Special-case the unique-index violation on (company_id, supplier_id, + // supplier_invoice_number). The UI uses the embedded `existing` object + // to offer "undo crediting" — preserve that shape inside `details`. + const pgErr = invoiceError as { code?: string; message?: string } | null + const isDuplicateNumber = + pgErr?.code === '23505' && + (pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number') + + if (isDuplicateNumber) { + const { data: existing } = await supabase .from('supplier_invoices') - .select('id') + .select('id, supplier_invoice_number, status') .eq('company_id', companyId) - .eq('credited_invoice_id', existing.id) - .eq('is_credit_note', true) + .eq('supplier_id', body.supplier_id) + .eq('supplier_invoice_number', body.supplier_invoice_number) .maybeSingle() - creditNoteId = creditNote?.id ?? null - } - const statusLabels: Record = { - registered: 'registrerad', - approved: 'godkänd', - paid: 'betald', - partially_paid: 'delbetald', - overdue: 'förfallen', - disputed: 'tvist', - credited: 'krediterad', - } - const statusLabel = statusLabels[existing.status] || existing.status - const message = - existing.status === 'credited' - ? `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (krediterad). Du kan ångra krediteringen för att frigöra numret, eller använda ett annat nummer.` - : `Det finns redan en faktura med nummer ${existing.supplier_invoice_number} från denna leverantör (status: ${statusLabel}). Använd ett annat nummer.` + let creditNoteId: string | null = null + if (existing?.status === 'credited') { + const { data: creditNote } = await supabase + .from('supplier_invoices') + .select('id') + .eq('company_id', companyId) + .eq('credited_invoice_id', existing.id) + .eq('is_credit_note', true) + .maybeSingle() + creditNoteId = creditNote?.id ?? null + } - return NextResponse.json( - { - error: 'duplicate_supplier_invoice_number', - message, - existing: { - id: existing.id, - supplier_invoice_number: existing.supplier_invoice_number, - status: existing.status, - credit_note_id: creditNoteId, + return errorResponseFromCode('SI_CREATE_DUPLICATE_INVOICE_NUMBER', log, { + requestId, + details: { + supplierId: body.supplier_id, + supplierInvoiceNumber: body.supplier_invoice_number, + existing: existing + ? { + id: existing.id, + supplier_invoice_number: existing.supplier_invoice_number, + status: existing.status, + credit_note_id: creditNoteId, + } + : null, }, - }, - { status: 409 } - ) - } - - return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 }) - } - - // Insert line items - const itemInserts = items.map((item) => ({ - supplier_invoice_id: invoice.id, - ...item, - })) - - const { error: itemsError } = await supabase - .from('supplier_invoice_items') - .insert(itemInserts) - - if (itemsError) { - // Clean up invoice on items failure - await supabase.from('supplier_invoices').delete().eq('id', invoice.id) - return NextResponse.json({ error: itemsError.message }, { status: 500 }) - } - - // Accrual method: create registration journal entry - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - let registrationJournalEntryId: string | null = null - - if (accountingMethod === 'accrual') { - try { - const journalEntry = await createSupplierInvoiceRegistrationEntry( - supabase, - companyId, - user.id, - invoice as SupplierInvoice, - items as SupplierInvoiceItem[], - supplier.supplier_type, - supplier.name - ) - if (journalEntry) { - registrationJournalEntryId = journalEntry.id - await supabase - .from('supplier_invoices') - .update({ registration_journal_entry_id: journalEntry.id }) - .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) - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - 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 } - ) + log.error('supplier invoice insert failed', invoiceError) + return errorResponseFromCode('SI_CREATE_FAILED', log, { + requestId, + details: { reason: invoiceError?.message || 'unknown' }, + }) } - } - try { - await eventBus.emit({ - type: 'supplier_invoice.registered', - payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId: user.id }, + const itemInserts = items.map((item) => ({ + supplier_invoice_id: invoice.id, + ...item, + })) + + const { error: itemsError } = await supabase + .from('supplier_invoice_items') + .insert(itemInserts) + + if (itemsError) { + // Roll back the parent on items failure to avoid orphan rows. + await supabase.from('supplier_invoices').delete().eq('id', invoice.id) + log.error('supplier invoice items insert failed; rolled back', itemsError, { + invoiceId: invoice.id, + }) + return errorResponseFromCode('SI_CREATE_FAILED', log, { + requestId, + details: { reason: itemsError.message, step: 'items_insert' }, + }) + } + + // Accrual method: create the registration journal entry. JE failure here + // is fatal — an orphan supplier_invoices row without a registration JE + // silently understates leverantörsskuld (2440) and ingående moms (2641) + // for the momsdeklaration. Roll back instead. + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + let registrationJournalEntryId: string | null = null + + if (accountingMethod === 'accrual') { + try { + const journalEntry = await createSupplierInvoiceRegistrationEntry( + supabase, + companyId!, + user.id, + invoice as SupplierInvoice, + items as SupplierInvoiceItem[], + supplier.supplier_type, + supplier.name, + ) + if (journalEntry) { + registrationJournalEntryId = journalEntry.id + await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: journalEntry.id }) + .eq('id', invoice.id) + } + } catch (err) { + await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) + if (isBookkeepingError(err)) { + return errorResponse(err, log, { requestId }) + } + log.error('failed to create registration journal entry', err as Error, { + invoiceId: invoice.id, + }) + return errorResponseFromCode('SI_CREATE_FAILED', log, { + requestId, + details: { + reason: err instanceof Error ? err.message : 'unknown', + step: 'registration_journal_entry', + }, + }) + } + } + + try { + await eventBus.emit({ + type: 'supplier_invoice.registered', + payload: { supplierInvoice: invoice as SupplierInvoice, companyId: companyId!, userId: user.id }, + }) + } catch (err) { + log.warn('supplier_invoice.registered event emission failed', err as Error) + } + + return NextResponse.json({ + data: { + ...invoice, + items: itemInserts, + registration_journal_entry_id: registrationJournalEntryId, + }, }) - } catch { - // Non-blocking — event emission failure should not affect the response - } - - return NextResponse.json({ - data: { - ...invoice, - items: itemInserts, - registration_journal_entry_id: registrationJournalEntryId, - }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts index 09a06677..d6bdfc2b 100644 --- a/app/api/suppliers/[id]/route.ts +++ b/app/api/suppliers/[id]/route.ts @@ -1,166 +1,154 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const GET = withRouteContext( + 'supplier.get', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierId: id }) - const { data: { user } } = await supabase.auth.getUser() + const { data: supplier, error } = await supabase + .from('suppliers') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch supplier - const { data: supplier, error } = await supabase - .from('suppliers') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error || !supplier) { - return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) - } - - // Fetch stats: total outstanding & total paid - const { data: invoices } = await supabase - .from('supplier_invoices') - .select('status, total, remaining_amount, paid_amount') - .eq('supplier_id', id) - .eq('company_id', companyId) - - const stats = { - total_outstanding: 0, - total_paid: 0, - invoice_count: 0, - } - - if (invoices) { - stats.invoice_count = invoices.length - for (const inv of invoices) { - if (inv.status !== 'paid' && inv.status !== 'credited') { - stats.total_outstanding += inv.remaining_amount || 0 - } - stats.total_paid += inv.paid_amount || 0 + if (error || !supplier) { + return errorResponseFromCode('SUPPLIER_NOT_FOUND', opLog, { requestId }) } - } - return NextResponse.json({ data: { ...supplier, stats } }) -} + const { data: invoices } = await supabase + .from('supplier_invoices') + .select('status, total, remaining_amount, paid_amount') + .eq('supplier_id', id) + .eq('company_id', companyId) -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params + const stats = { + total_outstanding: 0, + total_paid: 0, + invoice_count: 0, + } - const { data: { user } } = await supabase.auth.getUser() + if (invoices) { + stats.invoice_count = invoices.length + for (const inv of invoices) { + if (inv.status !== 'paid' && inv.status !== 'credited') { + stats.total_outstanding += inv.remaining_amount || 0 + } + stats.total_paid += inv.paid_amount || 0 + } + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return NextResponse.json({ data: { ...supplier, stats } }) + }, +) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response +export const PUT = withRouteContext( + 'supplier.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierId: id }) - const companyId = await requireCompanyId(supabase, user.id) - - const result = await validateBody(request, UpdateSupplierSchema) - if (!result.success) return result.response - const body = result.data - - const { data, error } = await supabase - .from('suppliers') - .update({ - name: body.name, - supplier_type: body.supplier_type, - email: body.email, - phone: body.phone, - address_line1: body.address_line1, - address_line2: body.address_line2, - postal_code: body.postal_code, - city: body.city, - country: body.country, - org_number: body.org_number, - vat_number: body.vat_number, - bankgiro: body.bankgiro, - plusgiro: body.plusgiro, - bank_account: body.bank_account, - iban: body.iban, - bic: body.bic, - default_expense_account: body.default_expense_account, - default_payment_terms: body.default_payment_terms, - default_currency: body.default_currency, - notes: body.notes, + const result = await validateBody(request, UpdateSupplierSchema, { + log: opLog, + operation: 'supplier.update', }) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() + if (!result.success) return result.response + const body = result.data - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + const { data, error } = await supabase + .from('suppliers') + .update({ + name: body.name, + supplier_type: body.supplier_type, + email: body.email, + phone: body.phone, + address_line1: body.address_line1, + address_line2: body.address_line2, + postal_code: body.postal_code, + city: body.city, + country: body.country, + org_number: body.org_number, + vat_number: body.vat_number, + bankgiro: body.bankgiro, + plusgiro: body.plusgiro, + bank_account: body.bank_account, + iban: body.iban, + bic: body.bic, + default_expense_account: body.default_expense_account, + default_payment_terms: body.default_payment_terms, + default_currency: body.default_currency, + notes: body.notes, + }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() - return NextResponse.json({ data }) -} + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', opLog, { + requestId, + details: { orgNumber: body.org_number }, + }) + } + opLog.error('supplier update failed', error) + return errorResponseFromCode('SUPPLIER_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) - const { data: { user } } = await supabase.auth.getUser() +export const DELETE = withRouteContext( + 'supplier.delete', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ supplierId: id }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { count } = await supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('supplier_id', id) + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (count && count > 0) { + return errorResponseFromCode('SUPPLIER_DELETE_FAILED', opLog, { + requestId, + details: { reason: 'has_invoices', invoiceCount: count }, + }) + } - const companyId = await requireCompanyId(supabase, user.id) + const { error, count: deleteCount } = await supabase + .from('suppliers') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('company_id', companyId) - // Check for linked invoices - const { count } = await supabase - .from('supplier_invoices') - .select('id', { count: 'exact', head: true }) - .eq('supplier_id', id) - .eq('company_id', companyId) + if (error) { + opLog.error('supplier delete failed', error) + return errorResponseFromCode('SUPPLIER_DELETE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } - if (count && count > 0) { - return NextResponse.json( - { error: 'Kan inte ta bort leverantör med kopplade fakturor' }, - { status: 400 } - ) - } + if (deleteCount === 0) { + return errorResponseFromCode('SUPPLIER_NOT_FOUND', opLog, { requestId }) + } - const { error, count: deleteCount } = await supabase - .from('suppliers') - .delete({ count: 'exact' }) - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - if (deleteCount === 0) { - return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index 90b59b64..114501a8 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -1,84 +1,85 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { CreateSupplierSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -export async function GET() { - const supabase = await createClient() +export const GET = withRouteContext( + 'supplier.list', + async (_request, ctx) => { + const { supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { data, error } = await supabase + .from('suppliers') + .select('*') + .eq('company_id', companyId) + .order('name', { ascending: true }) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + log.error('supplier list failed', error) + return errorResponse(error, log, { requestId }) + } - const companyId = await requireCompanyId(supabase, user.id) + return NextResponse.json({ data }) + }, +) - const { data, error } = await supabase - .from('suppliers') - .select('*') - .eq('company_id', companyId) - .order('name', { ascending: true }) +export const POST = withRouteContext( + 'supplier.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const result = await validateBody(request, CreateSupplierSchema) - if (!result.success) return result.response - const body = result.data - - const { data, error } = await supabase - .from('suppliers') - .insert({ - user_id: user.id, - company_id: companyId, - name: body.name, - supplier_type: body.supplier_type, - email: body.email, - phone: body.phone, - address_line1: body.address_line1, - address_line2: body.address_line2, - postal_code: body.postal_code, - city: body.city, - country: body.country || 'SE', - org_number: body.org_number, - vat_number: body.vat_number, - bankgiro: body.bankgiro, - plusgiro: body.plusgiro, - bank_account: body.bank_account, - iban: body.iban, - bic: body.bic, - default_expense_account: body.default_expense_account, - default_payment_terms: body.default_payment_terms || 30, - default_currency: body.default_currency || 'SEK', - notes: body.notes, + const result = await validateBody(request, CreateSupplierSchema, { + log, + operation: 'supplier.create', }) - .select() - .single() + if (!result.success) return result.response + const body = result.data - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + const { data, error } = await supabase + .from('suppliers') + .insert({ + user_id: user.id, + company_id: companyId, + name: body.name, + supplier_type: body.supplier_type, + email: body.email, + phone: body.phone, + address_line1: body.address_line1, + address_line2: body.address_line2, + postal_code: body.postal_code, + city: body.city, + country: body.country || 'SE', + org_number: body.org_number, + vat_number: body.vat_number, + bankgiro: body.bankgiro, + plusgiro: body.plusgiro, + bank_account: body.bank_account, + iban: body.iban, + bic: body.bic, + default_expense_account: body.default_expense_account, + default_payment_terms: body.default_payment_terms || 30, + default_currency: body.default_currency || 'SEK', + notes: body.notes, + }) + .select() + .single() - return NextResponse.json({ data }) -} + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', log, { + requestId, + details: { orgNumber: body.org_number }, + }) + } + log.error('supplier insert failed', error) + return errorResponseFromCode('SUPPLIER_CREATE_FAILED', log, { + requestId, + details: { reason: error.message }, + }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/tax-deadlines/cron/route.ts b/app/api/tax-deadlines/cron/route.ts index e7c282a3..7c7e4704 100644 --- a/app/api/tax-deadlines/cron/route.ts +++ b/app/api/tax-deadlines/cron/route.ts @@ -1,47 +1,35 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { generateNewYearDeadlines } from '@/lib/tax/deadline-generator' -import { verifyCronSecret } from '@/lib/auth/cron' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' /** - * GET /api/tax-deadlines/cron - * Annual cron job to generate tax deadlines for the new year - * Runs on January 2nd - * - * Vercel Cron: "0 0 2 1 *" (midnight on January 2nd) + * GET /api/tax-deadlines/cron — annual on January 2nd 00:00. + * Generates the next year's tax deadlines for every company. */ -export async function GET(request: Request) { - const authError = verifyCronSecret(request) - if (authError) return authError - - // Create a service role client for accessing all user data +export const GET = withCronContext('cron.tax_deadlines', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !supabaseServiceKey) { - return NextResponse.json( - { error: 'Missing Supabase configuration' }, - { status: 500 } - ) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) } const supabase = createClient(supabaseUrl, supabaseServiceKey) + const result = await generateNewYearDeadlines(supabase) - try { - const result = await generateNewYearDeadlines(supabase) + ctx.log.info('tax deadlines cron summary', { + usersProcessed: result.usersProcessed, + totalCreated: result.totalCreated, + }) - console.log(`Tax deadlines cron completed: ${result.usersProcessed} users, ${result.totalCreated} deadlines created`) - - return NextResponse.json({ - success: true, - usersProcessed: result.usersProcessed, - totalCreated: result.totalCreated, - }) - } catch (error) { - console.error('Error in tax deadlines cron:', error) - return NextResponse.json( - { error: 'Failed to generate tax deadlines' }, - { status: 500 } - ) - } -} + return NextResponse.json({ + success: true, + usersProcessed: result.usersProcessed, + totalCreated: result.totalCreated, + }) +}) diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 7c6384fe..3abe9c18 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -96,7 +96,7 @@ describe('POST /api/transactions/[id]/categorize', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Transaction not found') + expect((body.error as unknown as { code: string }).code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') }) it('updates category only when transaction already has journal entry', async () => { @@ -241,7 +241,7 @@ describe('POST /api/transactions/[id]/categorize', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Failed to update transaction') + expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') }) it('returns 400 when mapping result has empty debit_account', async () => { @@ -267,7 +267,7 @@ describe('POST /api/transactions/[id]/categorize', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Invalid account mapping: debit and credit accounts are required') + expect((body.error as unknown as { code: string }).code).toBe('TX_CATEGORIZE_INVALID_MAPPING') expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index ee1029e5..e567b0d6 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -1,16 +1,17 @@ -import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' -import { getErrorMessage } from '@/lib/errors/get-error-message' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import type { Logger } from '@/lib/logger' import type { CategorizationTemplate } from '@/types' import { validateBody } from '@/lib/api/validate' import { CategorizeTransactionSchema } from '@/lib/api/schemas' @@ -19,16 +20,16 @@ import type { Transaction, TransactionCategory, EntityType } from '@/types' ensureInitialized() /** - * Ensure a fiscal period exists for the given date, create one if needed + * Ensure a fiscal period exists for the given date, create one if needed. */ async function ensureFiscalPeriod( - supabase: Awaited>, + supabase: SupabaseClient, userId: string, companyId: string, date: string, - fiscalYearStartMonth: number = 1 + fiscalYearStartMonth: number, + log: Logger, ): Promise { - // Check if a fiscal period already covers this date const { data: existing } = await supabase .from('fiscal_periods') .select('id') @@ -38,11 +39,8 @@ async function ensureFiscalPeriod( .eq('is_closed', false) .limit(1) - if (existing && existing.length > 0) { - return true - } + if (existing && existing.length > 0) return true - // Compute fiscal year period based on start month const txDate = new Date(date) const txMonth = txDate.getMonth() + 1 const txYear = txDate.getFullYear() @@ -59,7 +57,6 @@ async function ensureFiscalPeriod( const startMonth = String(fiscalYearStartMonth).padStart(2, '0') const periodStart = `${periodStartYear}-${startMonth}-01` - // Period ends the day before the next fiscal year starts const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 const lastDay = new Date(endYear, endMonth, 0).getDate() @@ -82,289 +79,296 @@ async function ensureFiscalPeriod( }) if (error) { - console.error('Failed to create fiscal period:', error) + log.error('failed to create fiscal period', error) return false } return true } -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext( + 'transaction.categorize', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Parse and validate request body - const validation = await validateBody(request, CategorizeTransactionSchema) - if (!validation.success) return validation.response - const body = validation.data - const { is_business, category } = body - - // Fetch the transaction (validates ownership) - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - // If already has a journal entry, just update category and is_business (skip journal entry creation) - if (transaction.journal_entry_id) { - const finalCat: TransactionCategory = is_business - ? (category || 'uncategorized') - : 'private' - - const { error: updateErr } = await supabase - .from('transactions') - .update({ - is_business, - category: finalCat, - }) - .eq('id', id) - - if (updateErr) { - return NextResponse.json( - { error: 'Failed to update transaction' }, - { status: 500 } - ) - } - - return NextResponse.json({ - success: true, - journal_entry_created: false, - journal_entry_id: transaction.journal_entry_id, - journal_entry_error: null, - category: finalCat, - already_had_journal_entry: true, + const validation = await validateBody(request, CategorizeTransactionSchema, { + log, + operation: 'transaction.categorize', }) - } + if (!validation.success) return validation.response + const body = validation.data + const { is_business, category } = body - // Fetch company settings to get entity type and fiscal year start - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type, fiscal_year_start_month') - .eq('company_id', companyId) - .single() - - const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' - const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1 - - // Determine the category to use - let finalCategory: TransactionCategory - if (body.template_id) { - const template = getTemplateById(body.template_id) - if (template) { - // Hard entity guard — reject templates that don't match the user's entity type - const entityValidation = validateTemplateForEntity(template, entityType) - if (!entityValidation.valid) { - return NextResponse.json({ error: entityValidation.error }, { status: 400 }) - } - - finalCategory = is_business ? template.fallback_category : 'private' - console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`) - } else { - return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 }) - } - } else { - finalCategory = is_business ? (category || 'uncategorized') : 'private' - console.log(`[categorize] tx=${id} using category="${finalCategory}" vat=${body.vat_treatment || 'default'} account_override=${body.account_override || 'none'}`) - } - - if (body.inbox_item_id) { - console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`) - } - - // Build mapping result from template, counterparty template, or category - let mappingResult - if (body.counterparty_template_id && is_business) { - // Counterparty template — look up and build full multi-line MappingResult - const { data: cpTemplate } = await supabase - .from('categorization_templates') + const { data: transaction, error: fetchError } = await supabase + .from('transactions') .select('*') - .eq('id', body.counterparty_template_id) + .eq('id', id) .eq('company_id', companyId) - .eq('is_active', true) - .maybeSingle() - - if (!cpTemplate) { - return NextResponse.json({ error: 'Counterparty template not found' }, { status: 404 }) - } - - const match = { - template: cpTemplate as CategorizationTemplate, - matchMethod: 'exact_alias' as const, - confidence: Number(cpTemplate.confidence), - } - mappingResult = buildMappingResultFromCounterpartyTemplate(match, transaction as Transaction, entityType) - console.log(`[categorize] tx=${id} using counterparty template="${cpTemplate.counterparty_name}" lines=${cpTemplate.line_pattern ? 'multi' : 'simple'}`) - } else if (body.template_id) { - const template = getTemplateById(body.template_id)! - mappingResult = buildMappingResultFromTemplate( - template, - transaction as Transaction, - entityType - ) - } else { - mappingResult = buildMappingResultFromCategory( - finalCategory, - transaction as Transaction, - is_business, - entityType, - body.vat_treatment - ) - } - - console.log(`[categorize] tx=${id} mapping result:`, { - debit: mappingResult.debit_account, - credit: mappingResult.credit_account, - allLinesComplete: mappingResult.all_lines_complete || false, - vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`), - }) - - // Apply account override if provided (only for category-based booking, not templates) - if (is_business && body.account_override && !body.template_id && !body.counterparty_template_id) { - // Validate the account exists in the user's chart of accounts - const { data: accountExists } = await supabase - .from('chart_of_accounts') - .select('account_number, account_class') - .eq('company_id', companyId) - .eq('account_number', body.account_override) .single() - if (!accountExists) { - return NextResponse.json( - { error: 'Invalid account number' }, - { status: 400 } + if (fetchError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId }) + } + + const txLog = log.child({ transactionId: id }) + + // Already-categorized fast path: just update flags, leave the JE alone. + if (transaction.journal_entry_id) { + const finalCat: TransactionCategory = is_business ? (category || 'uncategorized') : 'private' + + const { error: updateErr } = await supabase + .from('transactions') + .update({ is_business, category: finalCat }) + .eq('id', id) + + if (updateErr) { + txLog.error('failed to update already-categorized transaction', updateErr) + return errorResponse(updateErr, txLog, { requestId }) + } + + return NextResponse.json({ + success: true, + journal_entry_created: false, + journal_entry_id: transaction.journal_entry_id, + journal_entry_error: null, + category: finalCat, + already_had_journal_entry: true, + }) + } + + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, fiscal_year_start_month') + .eq('company_id', companyId) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1 + + let finalCategory: TransactionCategory + if (body.template_id) { + const template = getTemplateById(body.template_id) + if (!template) { + return errorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, { + requestId, + details: { templateId: body.template_id, reason: 'unknown_template' }, + }) + } + const entityValidation = validateTemplateForEntity(template, entityType) + if (!entityValidation.valid) { + return errorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, { + requestId, + details: { templateId: body.template_id, reason: entityValidation.error }, + }) + } + finalCategory = is_business ? template.fallback_category : 'private' + txLog.info('using template', { + template: body.template_id, + templateName: template.name_sv, + category: finalCategory, + debit: template.debit_account, + credit: template.credit_account, + }) + } else { + finalCategory = is_business ? (category || 'uncategorized') : 'private' + txLog.info('using category', { + category: finalCategory, + vatTreatment: body.vat_treatment ?? null, + accountOverride: body.account_override ?? null, + }) + } + + let mappingResult + if (body.counterparty_template_id && is_business) { + const { data: cpTemplate } = await supabase + .from('categorization_templates') + .select('*') + .eq('id', body.counterparty_template_id) + .eq('company_id', companyId) + .eq('is_active', true) + .maybeSingle() + + if (!cpTemplate) { + return errorResponseFromCode('NOT_FOUND', txLog, { + requestId, + details: { resource: 'counterparty_template', id: body.counterparty_template_id }, + }) + } + + const match = { + template: cpTemplate as CategorizationTemplate, + matchMethod: 'exact_alias' as const, + confidence: Number(cpTemplate.confidence), + } + mappingResult = buildMappingResultFromCounterpartyTemplate(match, transaction as Transaction, entityType) + txLog.info('using counterparty template', { + counterparty: cpTemplate.counterparty_name, + lines: cpTemplate.line_pattern ? 'multi' : 'simple', + }) + } else if (body.template_id) { + const template = getTemplateById(body.template_id)! + mappingResult = buildMappingResultFromTemplate(template, transaction as Transaction, entityType) + } else { + mappingResult = buildMappingResultFromCategory( + finalCategory, + transaction as Transaction, + is_business, + entityType, + body.vat_treatment, ) } - // Apply override: expenses override debit account, income overrides credit account - if (transaction.amount < 0) { - mappingResult.debit_account = body.account_override - } else { - mappingResult.credit_account = body.account_override - } + txLog.info('mapping resolved', { + debit: mappingResult.debit_account, + credit: mappingResult.credit_account, + allLinesComplete: mappingResult.all_lines_complete || false, + vatLineCount: mappingResult.vat_lines.length, + }) - // If override account is a liability/equity account (class 2), clear VAT lines - if (accountExists.account_class === 2) { - mappingResult.vat_lines = [] - } - } - - // Validate that both accounts are present before proceeding - if (!mappingResult.debit_account || !mappingResult.credit_account) { - return NextResponse.json( - { error: 'Invalid account mapping: debit and credit accounts are required' }, - { status: 400 } - ) - } - - // Ensure fiscal period exists for the transaction date - await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth) - - // Try to create journal entry - let journalEntryCreated = false - let journalEntryId: string | null = null - let journalEntryError: string | null = null - let documentLinkWarning: string | null = null - - try { - const journalEntry = await createTransactionJournalEntry( - supabase, - companyId, - user.id, - transaction as Transaction, - mappingResult - ) - - if (journalEntry) { - journalEntryCreated = true - journalEntryId = journalEntry.id - } - } catch (err) { - console.error('Failed to create journal entry:', err) - // Typed bookkeeping errors: surface a Swedish translation in the response - // so the client toast can display it directly. - const typedResp = bookkeepingErrorResponse(err) - if (typedResp) { - const body = (await typedResp.json()) as unknown - journalEntryError = getErrorMessage(body, { context: 'transaction' }) - } else { - journalEntryError = err instanceof Error ? err.message : 'Unknown error' - } - // Continue - we still want to save the categorization - } - - // Save mapping rule for future auto-categorization (only for business expenses with merchant) - if (is_business && transaction.merchant_name) { - try { - await saveUserMappingRule( - supabase, - companyId, - transaction.merchant_name, - mappingResult.debit_account, - mappingResult.credit_account, - !is_business, - body.user_description, - body.template_id - ) - } catch (err) { - console.error('Failed to save mapping rule:', err) - // Non-critical, continue - } - } - - // Upsert counterparty template for future auto-matching - try { - await upsertCounterpartyTemplate( - supabase, user.id, transaction as Transaction, mappingResult, 'user_approved' - ) - } catch { - // Non-critical - } - - // Link receipt document to journal entry if both exist - if (journalEntryId && transaction.receipt_id) { - try { - const { data: receipt } = await supabase - .from('receipts') - .select('document_id') - .eq('id', transaction.receipt_id) + if (is_business && body.account_override && !body.template_id && !body.counterparty_template_id) { + const { data: accountExists } = await supabase + .from('chart_of_accounts') + .select('account_number, account_class') + .eq('company_id', companyId) + .eq('account_number', body.account_override) .single() - if (receipt?.document_id) { - await supabase + if (!accountExists) { + return errorResponseFromCode('TX_CATEGORIZE_INVALID_ACCOUNT', txLog, { + requestId, + details: { accountNumber: body.account_override }, + }) + } + + if (transaction.amount < 0) { + mappingResult.debit_account = body.account_override + } else { + mappingResult.credit_account = body.account_override + } + + if (accountExists.account_class === 2) { + mappingResult.vat_lines = [] + } + } + + if (!mappingResult.debit_account || !mappingResult.credit_account) { + return errorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, { + requestId, + details: { + debitAccount: mappingResult.debit_account, + creditAccount: mappingResult.credit_account, + }, + }) + } + + await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth, txLog) + + let journalEntryCreated = false + let journalEntryId: string | null = null + let journalEntryError: string | null = null + let documentLinkWarning: string | null = null + + try { + const journalEntry = await createTransactionJournalEntry( + supabase, + companyId, + user.id, + transaction as Transaction, + mappingResult, + ) + + if (journalEntry) { + journalEntryCreated = true + journalEntryId = journalEntry.id + } + } catch (err) { + txLog.error('failed to create transaction journal entry', err as Error) + // Bookkeeping errors map to Swedish via the registry. Other errors get + // their raw message — the categorization is preserved either way so the + // user can still re-book the verifikation manually. + if (isBookkeepingError(err)) { + journalEntryError = getErrorMessage(err, { context: 'transaction' }) + } else { + journalEntryError = err instanceof Error ? err.message : 'Unknown error' + } + } + + if (is_business && transaction.merchant_name) { + try { + await saveUserMappingRule( + supabase, + companyId, + transaction.merchant_name, + mappingResult.debit_account, + mappingResult.credit_account, + !is_business, + body.user_description, + body.template_id, + ) + } catch (err) { + txLog.warn('failed to save mapping rule (non-critical)', err as Error) + } + } + + try { + await upsertCounterpartyTemplate( + supabase, user.id, transaction as Transaction, mappingResult, 'user_approved', + ) + } catch (err) { + txLog.warn('failed to upsert counterparty template (non-critical)', err as Error) + } + + if (journalEntryId && transaction.receipt_id) { + try { + const { data: receipt } = await supabase + .from('receipts') + .select('document_id') + .eq('id', transaction.receipt_id) + .single() + + if (receipt?.document_id) { + await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', receipt.document_id) + .eq('company_id', companyId) + } + } catch (linkErr) { + txLog.warn('failed to link receipt document (non-critical)', linkErr as Error) + } + } else if (journalEntryId && transaction.document_id) { + // Document was pinned to the transaction (via /attach-document or MCP) before + // categorization. Propagate the link to the journal entry so + // receipt-on-verifikation (BFL 5 kap 6 §) is satisfied. The journal entry has + // already been committed at this point, so we can't roll it back; instead + // surface a warning in the response so the UI can prompt the user to retry + // the link. Supabase JS returns { error } rather than throwing — destructure + // and surface it, never swallow silently. + try { + const { error: linkErr } = await supabase .from('document_attachments') .update({ journal_entry_id: journalEntryId }) - .eq('id', receipt.document_id) + .eq('id', transaction.document_id) .eq('company_id', companyId) + if (linkErr) { + txLog.error('failed to link transaction document', linkErr, { + documentId: transaction.document_id, + }) + documentLinkWarning = + 'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.' + } + } catch (docErr) { + txLog.error('failed to link transaction document', docErr as Error, { + documentId: transaction.document_id, + }) + documentLinkWarning = + 'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.' } - } catch (linkErr) { - console.error('[categorize] Failed to link receipt document:', linkErr) } - } - // Link the matched inbox item's document to the journal entry - if (body.inbox_item_id) { - try { - if (journalEntryId) { + if (body.inbox_item_id && journalEntryId) { + try { const { data: inboxItem } = await supabase .from('invoice_inbox_items') .select('document_id') @@ -379,103 +383,84 @@ export async function POST( .eq('id', inboxItem.document_id) .eq('company_id', companyId) } + } catch (inboxErr) { + txLog.warn('failed to link inbox document (non-critical)', inboxErr as Error) } - } catch (inboxErr) { - console.error('[categorize] Failed to update inbox item:', inboxErr) } - } else if (journalEntryId && transaction.document_id) { - // Document was pinned to the transaction (via /attach-document or MCP) before - // categorization. Propagate the link to the journal entry so receipt-on-verifikation - // (BFL 5 kap 6 §) is satisfied. The journal entry has already been committed at - // this point, so we can't roll it back; instead surface a warning in the response - // so the UI can prompt the user to retry the link. Supabase JS returns { error } - // rather than throwing — destructure and surface it, never swallow silently. - try { - const { error: linkErr } = await supabase - .from('document_attachments') - .update({ journal_entry_id: journalEntryId }) - .eq('id', transaction.document_id) - .eq('company_id', companyId) - if (linkErr) { - console.error('[categorize] Failed to link transaction document:', linkErr) - documentLinkWarning = - 'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.' - } - } catch (docErr) { - console.error('[categorize] Failed to link transaction document:', docErr) - documentLinkWarning = - 'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.' - } - } - // Update the transaction (CAS guard: only set journal_entry_id if still null) - const { data: updateResult, error: updateError } = await supabase - .from('transactions') - .update({ - is_business, - category: finalCategory, - journal_entry_id: journalEntryId, + const { data: updateResult, error: updateError } = await supabase + .from('transactions') + .update({ + is_business, + category: finalCategory, + journal_entry_id: journalEntryId, + }) + .eq('id', id) + .is('journal_entry_id', null) + .select('id') + + if (updateError) { + txLog.error('failed to update transaction', updateError) + return errorResponse(updateError, txLog, { requestId }) + } + + if ((!updateResult || updateResult.length === 0) && journalEntryId) { + // CAS guard: another request set journal_entry_id between our read and + // write. Cancel the orphaned entry and document the voucher gap. + const { data: orphan } = await supabase + .from('journal_entries') + .select('fiscal_period_id, voucher_series, voucher_number') + .eq('id', journalEntryId) + .single() + + await supabase + .from('journal_entries') + .update({ status: 'cancelled' }) + .eq('id', journalEntryId) + + if (orphan) { + await supabase.from('voucher_gap_explanations').insert({ + company_id: companyId, + fiscal_period_id: orphan.fiscal_period_id, + voucher_series: orphan.voucher_series || 'A', + gap_number: orphan.voucher_number, + explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + created_by: user.id, + }) + } + + return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId }) + } + + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: mappingResult.debit_account, + taxCode: mappingResult.vat_lines[0]?.account_number || '', + userId: user.id, + companyId, + }, }) - .eq('id', id) - .is('journal_entry_id', null) - .select('id') - if (updateError) { - console.error('Failed to update transaction:', updateError) - return NextResponse.json( - { error: 'Failed to update transaction' }, - { status: 500 } - ) - } - - // CAS guard: another request already set journal_entry_id - if ((!updateResult || updateResult.length === 0) && journalEntryId) { - // Cancel the orphaned journal entry and document the voucher gap - const { data: orphan } = await supabase - .from('journal_entries') - .select('fiscal_period_id, voucher_series, voucher_number') - .eq('id', journalEntryId) - .single() - - await supabase - .from('journal_entries') - .update({ status: 'cancelled' }) - .eq('id', journalEntryId) - - if (orphan) { - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series || 'A', - gap_number: orphan.voucher_number, - explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - created_by: user.id, + if (journalEntryError) { + // Categorization stuck but the verifikation didn't make it through. + // Surface as a structured warning — the response below carries the + // user-facing message in `journal_entry_error`. + txLog.warn('partial outcome: journal entry creation failed', { + reason: 'journal_entry_creation_failed', + message: journalEntryError, }) } - return NextResponse.json( - { error: 'Transaction was already categorized by another request' }, - { status: 409 } - ) - } - - await eventBus.emit({ - type: 'transaction.categorized', - payload: { - transaction: transaction as Transaction, - account: mappingResult.debit_account, - taxCode: mappingResult.vat_lines[0]?.account_number || '', - userId: user.id, - companyId, - }, - }) - - return NextResponse.json({ - success: true, - journal_entry_created: journalEntryCreated, - journal_entry_id: journalEntryId, - journal_entry_error: journalEntryError, - document_link_warning: documentLinkWarning, - category: finalCategory, - }) -} + return NextResponse.json({ + success: true, + journal_entry_created: journalEntryCreated, + journal_entry_id: journalEntryId, + journal_entry_error: journalEntryError, + document_link_warning: documentLinkWarning, + category: finalCategory, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index cc107c01..30b50128 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -100,7 +100,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Transaction not found') + expect((body.error as unknown as { code: string }).code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') }) it('returns 400 when transaction is an expense (amount <= 0)', async () => { @@ -115,7 +115,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Only income transactions can be matched to invoices') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INCOME') }) it('returns 400 when transaction is already linked to an invoice', async () => { @@ -130,7 +130,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Transaction is already linked to an invoice') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED') }) it('returns 404 when invoice not found', async () => { @@ -146,7 +146,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(404) - expect(body.error).toBe('Invoice not found') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_FOUND') }) it('returns 400 when invoice is not in unpaid state', async () => { @@ -163,7 +163,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Invoice is not in an unpaid state') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_OPEN') }) it('matches transaction to invoice with accrual method (full payment)', async () => { @@ -305,7 +305,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) - expect(body.error).toBe('Failed to reverse conflicting journal entry') + // Storno failures bubble up through the bookkeeping engine; the wrapper + // routes any non-typed error to INTERNAL_ERROR. + expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') // Invoice should NOT have been updated — no further DB calls after storno failure expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() }) @@ -417,7 +419,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(409) - expect(body.error).toContain('already been fully paid') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_ALREADY_PAID') }) it('returns 409 on duplicate invoice_payment (unique constraint)', async () => { @@ -447,7 +449,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(409) - expect(body.error).toContain('already matched') + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_DUPLICATE_PAYMENT') }) it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => { diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 9a95f681..195be188 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -1,23 +1,18 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' -import { - AccountsNotInChartError, - accountsNotInChartResponse, - bookkeepingErrorResponse, -} from '@/lib/bookkeeping/errors' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { validateBody } from '@/lib/api/validate' import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { EntityType, Invoice, Transaction } from '@/types' ensureInitialized() @@ -34,310 +29,245 @@ ensureInitialized() * - Debit 1930 Företagskonto (Bank) * - Credit 1510 Kundfordringar (Accounts Receivable) */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const POST = withRouteContext( + 'transaction.match_invoice', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id: transactionId } = await params + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, MatchInvoiceSchema, { + log, + operation: 'transaction.match_invoice', + }) + if (!validation.success) return validation.response + const { invoice_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const txLog = log.child({ transactionId, invoiceId: invoice_id }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: transaction, error: fetchTxError } = await supabase + .from('transactions') + .select('*') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (fetchTxError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) + } - // Parse and validate request body - const validation = await validateBody(request, MatchInvoiceSchema) - if (!validation.success) return validation.response - const { invoice_id } = validation.data + if (transaction.amount <= 0) { + return errorResponseFromCode('MATCH_INVOICE_NOT_INCOME', txLog, { + requestId, + details: { amount: transaction.amount }, + }) + } - // Fetch the transaction (validates ownership) - const { data: transaction, error: fetchTxError } = await supabase - .from('transactions') - .select('*') - .eq('id', transactionId) - .eq('company_id', companyId) - .single() + if (transaction.invoice_id) { + return errorResponseFromCode('MATCH_INVOICE_TX_ALREADY_LINKED', txLog, { + requestId, + details: { existingInvoiceId: transaction.invoice_id }, + }) + } - if (fetchTxError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + const { data: invoice, error: fetchInvError } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', invoice_id) + .eq('company_id', companyId) + .single() - // Verify transaction is income (amount > 0) - if (transaction.amount <= 0) { - return NextResponse.json( - { error: 'Only income transactions can be matched to invoices' }, - { status: 400 } - ) - } + if (fetchInvError || !invoice) { + return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, { requestId }) + } - // Check if transaction is already linked to an invoice - if (transaction.invoice_id) { - return NextResponse.json( - { error: 'Transaction is already linked to an invoice' }, - { status: 400 } - ) - } + if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { + return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, { + requestId, + details: { currentStatus: invoice.status }, + }) + } - // Fetch the invoice with items (validates ownership, items needed for per-line VAT) - const { data: invoice, error: fetchInvError } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', invoice_id) - .eq('company_id', companyId) - .single() + // Storno conflicting auto-categorization JE before any other state change. + // If storno fails, return immediately — nothing else has been modified. + if (transaction.journal_entry_id) { + try { + await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) - if (fetchInvError || !invoice) { - return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) - } + const { error: clearJeError } = await supabase + .from('transactions') + .update({ journal_entry_id: null }) + .eq('id', transactionId) + if (clearJeError) { + txLog.warn('failed to clear journal_entry_id after storno', clearJeError) + } - // Verify invoice is in a matchable state (sent, overdue, or partially_paid) - if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { - return NextResponse.json( - { error: 'Invoice is not in an unpaid state' }, - { status: 400 } - ) - } - - // --- Commit 1: Storno conflicting auto-categorization journal entry --- - // Order: storno MUST complete before any other state changes. - // If storno fails, return 500 immediately — nothing else has been modified. - if (transaction.journal_entry_id) { - try { - await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) - - // Clear the journal_entry_id on the transaction - const { error: clearJeError } = await supabase - .from('transactions') - .update({ journal_entry_id: null }) - .eq('id', transactionId) - if (clearJeError) { - console.error('Failed to clear journal_entry_id after storno:', clearJeError) + logMatchEvent(supabase, user.id, transactionId, 'storno_conflict_resolved', { + invoiceId: invoice_id, + previousState: { journal_entry_id: transaction.journal_entry_id }, + newState: { journal_entry_id: null }, + }) + } catch (err) { + txLog.error('failed to storno conflicting journal entry', err as Error) + return errorResponse(err, txLog, { requestId }) } + } - logMatchEvent(supabase, user.id, transactionId, 'storno_conflict_resolved', { - invoiceId: invoice_id, - previousState: { journal_entry_id: transaction.journal_entry_id }, - newState: { journal_entry_id: null }, + const now = new Date().toISOString() + const paidAmount = transaction.amount + + const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 + const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) + const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) + const isFullyPaid = newRemaining <= 0 + const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', companyId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + let journalEntryId: string | null = null + let journalEntryError: string | null = null + + try { + if (accountingMethod === 'cash' && isFullyPaid) { + const journalEntry = await createInvoiceCashEntry( + supabase, companyId, user.id, invoice as Invoice, transaction.date, + entityType, invoice.customer?.name, + ) + journalEntryId = journalEntry?.id ?? null + } else { + // Accrual or cash partial: clearing entry against 1510. The cash-method + // partial path is intentional — under kontantmetoden 1510 has no prior + // balance, so this leaves a credit on 1510 that gets resolved when the + // final payment lands and createInvoiceCashEntry runs. + const journalEntry = await createInvoicePaymentJournalEntry( + supabase, companyId, user.id, invoice as Invoice, transaction.date, + undefined, invoice.customer?.name, paidAmount, + ) + journalEntryId = journalEntry?.id ?? null + } + } catch (err) { + // AccountsNotInChart is fatal so the UI can open the activation dialog. + if (err instanceof AccountsNotInChartError) { + return errorResponse(err, txLog, { requestId }) + } + txLog.error('failed to create payment journal entry', err as Error) + // Other errors are recorded but don't abort the match — the user can + // re-book the verifikation manually. + if (isBookkeepingError(err)) { + journalEntryError = getErrorMessage(err, { context: 'invoice' }) + } else { + journalEntryError = err instanceof Error ? err.message : 'Unknown error' + } + } + + // Optimistic lock: only update if invoice is still in a matchable state. + const { data: updatedRows, error: updateInvError } = await supabase + .from('invoices') + .update({ + status: newStatus, + paid_at: isFullyPaid ? now : null, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + }) + .eq('id', invoice_id) + .in('status', ['sent', 'overdue', 'partially_paid']) + .select('id') + + if (updateInvError) { + txLog.error('failed to update invoice status', updateInvError) + return errorResponse(updateInvError, txLog, { requestId }) + } + + if (!updatedRows || updatedRows.length === 0) { + return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId }) + } + + const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid) + ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' + : null + + const { error: paymentInsertError } = await supabase + .from('invoice_payments') + .insert({ + user_id: user.id, + company_id: companyId, + invoice_id, + payment_date: transaction.date, + amount: paidAmount, + currency: invoice.currency, + exchange_rate: invoice.exchange_rate, + journal_entry_id: journalEntryId, + transaction_id: transactionId, + notes: paymentNotes, + }) + + if (paymentInsertError) { + if (paymentInsertError.code === '23505') { + return errorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, { requestId }) + } + txLog.error('failed to record invoice payment', paymentInsertError) + return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) + } + + const { error: updateTxError } = await supabase + .from('transactions') + .update({ + invoice_id: invoice_id, + potential_invoice_id: null, + journal_entry_id: journalEntryId, + is_business: true, + category: 'income_services', + }) + .eq('id', transactionId) + + if (updateTxError) { + txLog.error('failed to link transaction to invoice', updateTxError) + return errorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, { requestId }) + } + + logMatchEvent(supabase, user.id, transactionId, 'matched', { + invoiceId: invoice_id, + matchConfidence: 1.0, + matchMethod: 'manual_confirm', + newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining }, + }) + + try { + eventBus.emit({ + type: 'invoice.match_confirmed', + payload: { + invoice: invoice as Invoice, + transaction: transaction as Transaction, + userId: user.id, + companyId, + }, }) } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - console.error('Failed to storno conflicting journal entry:', err) - return NextResponse.json( - { error: 'Failed to reverse conflicting journal entry' }, - { status: 500 } - ) + txLog.warn('invoice.match_confirmed event emission failed', err as Error) } - } - const now = new Date().toISOString() - const paidAmount = transaction.amount - - // Calculate partial payment amounts - const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 - const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) - const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) - const isFullyPaid = newRemaining <= 0 - const newStatus = isFullyPaid ? 'paid' : 'partially_paid' - - // Fetch accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method, entity_type') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - // Create journal entry for payment receipt (method-aware) - let journalEntryId: string | null = null - let journalEntryError: string | null = null - - try { - if (accountingMethod === 'cash' && isFullyPaid) { - // Kontantmetoden, full payment: combined revenue entry with per-line VAT rates - const journalEntry = await createInvoiceCashEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - transaction.date, - entityType, - invoice.customer?.name - ) - journalEntryId = journalEntry?.id ?? null - } else if (accountingMethod === 'cash' && !isFullyPaid) { - // Kontantmetoden, partial payment: use accrual-style clearing entry. - // Under kontantmetoden, invoice creation produces no journal entry, - // so 1510 has no prior balance. The debit 1930 / credit 1510 creates - // a credit on 1510 with no offsetting debit — this is intentional. - // 1510 is used as a temporary clearing account under cash method. - // The full revenue + VAT recognition (with 1510 reversal) happens at - // final payment when createInvoiceCashEntry is called. - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - transaction.date, - undefined, - invoice.customer?.name, - paidAmount - ) - journalEntryId = journalEntry?.id ?? null - } else { - // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - transaction.date, - undefined, - invoice.customer?.name, - paidAmount - ) - journalEntryId = journalEntry?.id ?? null + if (journalEntryError) { + txLog.warn('match recorded but payment journal entry failed', { + errorCode: 'MATCH_INVOICE_PARTIAL', + message: journalEntryError, + }) } - } catch (err) { - // AccountsNotInChart returns the structured 400 so the UI can open the - // account-activation dialog. Other errors are logged and attached to - // `journal_entry_error` — the match itself is a valuable business event, - // and the user can re-book the payment verifikation separately. - if (err instanceof AccountsNotInChartError) { - return accountsNotInChartResponse(err) - } - console.error('Failed to create payment journal entry:', err) - const typedResp = bookkeepingErrorResponse(err) - if (typedResp) { - const body = (await typedResp.json()) as unknown - journalEntryError = getErrorMessage(body, { context: 'invoice' }) - } else { - journalEntryError = err instanceof Error ? err.message : 'Unknown error' - } - // Continue - we still want to update the invoice and transaction - } - // --- Commit 4: Optimistic lock on invoice status --- - // Only update if invoice is still in a matchable state. - // Prevents TOCTOU race where another request fully pays the invoice - // between our fetch and this update. - const { data: updatedRows, error: updateInvError } = await supabase - .from('invoices') - .update({ - status: newStatus, + return NextResponse.json({ + success: true, + invoice_status: newStatus, paid_at: isFullyPaid ? now : null, paid_amount: newPaidAmount, remaining_amount: newRemaining, - }) - .eq('id', invoice_id) - .in('status', ['sent', 'overdue', 'partially_paid']) - .select('id') - - if (updateInvError) { - console.error('Failed to update invoice:', updateInvError) - return NextResponse.json( - { error: 'Failed to update invoice status' }, - { status: 500 } - ) - } - - if (!updatedRows || updatedRows.length === 0) { - return NextResponse.json( - { error: 'Invoice has already been fully paid or is no longer matchable' }, - { status: 409 } - ) - } - - // Record payment in invoice_payments table - const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid) - ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' - : null - - const { error: paymentInsertError } = await supabase - .from('invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - invoice_id, - payment_date: transaction.date, - amount: paidAmount, - currency: invoice.currency, - exchange_rate: invoice.exchange_rate, journal_entry_id: journalEntryId, - transaction_id: transactionId, - notes: paymentNotes, + journal_entry_error: journalEntryError, }) - - if (paymentInsertError) { - // Catch unique constraint violation (same transaction matched to same invoice twice) - if (paymentInsertError.code === '23505') { - return NextResponse.json( - { error: 'This transaction is already matched to this invoice' }, - { status: 409 } - ) - } - console.error('Failed to record invoice payment:', paymentInsertError) - return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 }) - } - - // Update transaction to link to invoice and clear potential match - const { error: updateTxError } = await supabase - .from('transactions') - .update({ - invoice_id: invoice_id, - potential_invoice_id: null, - journal_entry_id: journalEntryId, - is_business: true, - category: 'income_services', - }) - .eq('id', transactionId) - - if (updateTxError) { - console.error('Failed to update transaction:', updateTxError) - return NextResponse.json( - { error: 'Failed to link transaction to invoice' }, - { status: 500 } - ) - } - - // Log the match event and emit event - logMatchEvent(supabase, user.id, transactionId, 'matched', { - invoiceId: invoice_id, - matchConfidence: 1.0, - matchMethod: 'manual_confirm', - newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining }, - }) - - try { - eventBus.emit({ - type: 'invoice.match_confirmed', - payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, - userId: user.id, - companyId, - }, - }) - } catch { - // Event emission is non-critical - } - - return NextResponse.json({ - success: true, - invoice_status: newStatus, - paid_at: isFullyPaid ? now : null, - paid_amount: newPaidAmount, - remaining_amount: newRemaining, - journal_entry_id: journalEntryId, - journal_entry_error: journalEntryError, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 7bf99e55..08285256 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -1,17 +1,17 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' -import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { validateBody } from '@/lib/api/validate' import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' ensureInitialized() @@ -21,215 +21,203 @@ ensureInitialized() * * Match a negative transaction (expense) to a supplier invoice. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const POST = withRouteContext( + 'transaction.match_supplier_invoice', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id: transactionId } = await params + const { user, supabase, companyId, log, requestId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, MatchSupplierInvoiceSchema, { + log, + operation: 'transaction.match_supplier_invoice', + }) + if (!validation.success) return validation.response + const { supplier_invoice_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const txLog = log.child({ transactionId, supplierInvoiceId: supplier_invoice_id }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: transaction, error: fetchTxError } = await supabase + .from('transactions') + .select('*') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, MatchSupplierInvoiceSchema) - if (!validation.success) return validation.response - const { supplier_invoice_id } = validation.data - - // Fetch the transaction - const { data: transaction, error: fetchTxError } = await supabase - .from('transactions') - .select('*') - .eq('id', transactionId) - .eq('company_id', companyId) - .single() - - if (fetchTxError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - // Verify transaction is an expense (amount < 0) - if (transaction.amount >= 0) { - return NextResponse.json( - { error: 'Bara utgiftstransaktioner kan matchas mot leverantörsfakturor' }, - { status: 400 } - ) - } - - if (transaction.supplier_invoice_id) { - return NextResponse.json( - { error: 'Transaktionen är redan kopplad till en leverantörsfaktura' }, - { status: 400 } - ) - } - - // Fetch the invoice with supplier and items - const { data: invoice, error: fetchInvError } = await supabase - .from('supplier_invoices') - .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') - .eq('id', supplier_invoice_id) - .eq('company_id', companyId) - .single() - - if (fetchInvError || !invoice) { - return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 }) - } - - if (invoice.status === 'paid' || invoice.status === 'credited') { - return NextResponse.json( - { error: 'Leverantörsfakturan är redan betald' }, - { status: 400 } - ) - } - - const paymentAmount = Math.abs(transaction.amount) - const now = new Date().toISOString() - - // Get accounting method - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - - // Create journal entry - let journalEntryId: string | null = null - - try { - if (accountingMethod === 'cash') { - const journalEntry = await createSupplierInvoiceCashEntry( - supabase, - companyId, - user.id, - invoice as SupplierInvoice, - (invoice.items || []) as SupplierInvoiceItem[], - transaction.date, - invoice.supplier?.supplier_type || 'swedish_business' - ) - if (journalEntry) journalEntryId = journalEntry.id - } else { - const journalEntry = await createSupplierInvoicePaymentEntry( - supabase, - companyId, - user.id, - invoice as SupplierInvoice, - paymentAmount, - transaction.date - ) - if (journalEntry) journalEntryId = journalEntry.id + if (fetchTxError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) } - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - console.error('Failed to create payment journal entry:', err) - } - // Optimistic lock: only update if invoice is still in a matchable state - const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100) - const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 - const isFullyPaid = newRemaining <= 0 - const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + if (transaction.amount >= 0) { + return errorResponseFromCode('MATCH_SI_NOT_EXPENSE', txLog, { + requestId, + details: { amount: transaction.amount }, + }) + } - const { data: updatedRows, error: updateInvError } = await supabase - .from('supplier_invoices') - .update({ - status: newStatus, - remaining_amount: newRemaining, + if (transaction.supplier_invoice_id) { + return errorResponseFromCode('MATCH_SI_TX_ALREADY_LINKED', txLog, { + requestId, + details: { existingSupplierInvoiceId: transaction.supplier_invoice_id }, + }) + } + + const { data: invoice, error: fetchInvError } = await supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') + .eq('id', supplier_invoice_id) + .eq('company_id', companyId) + .single() + + if (fetchInvError || !invoice) { + return errorResponseFromCode('MATCH_SI_NOT_FOUND', txLog, { requestId }) + } + + if (invoice.status === 'paid' || invoice.status === 'credited') { + return errorResponseFromCode('MATCH_SI_ALREADY_PAID', txLog, { + requestId, + details: { currentStatus: invoice.status }, + }) + } + + const paymentAmount = Math.abs(transaction.amount) + const now = new Date().toISOString() + + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + + let journalEntryId: string | null = null + let journalEntryError: string | null = null + + try { + if (accountingMethod === 'cash') { + const journalEntry = await createSupplierInvoiceCashEntry( + supabase, companyId, user.id, invoice as SupplierInvoice, + (invoice.items || []) as SupplierInvoiceItem[], + transaction.date, + invoice.supplier?.supplier_type || 'swedish_business', + ) + if (journalEntry) journalEntryId = journalEntry.id + } else { + const journalEntry = await createSupplierInvoicePaymentEntry( + supabase, companyId, user.id, invoice as SupplierInvoice, + paymentAmount, transaction.date, + ) + if (journalEntry) journalEntryId = journalEntry.id + } + } catch (err) { + txLog.error('failed to create supplier invoice payment journal entry', err as Error) + // Bookkeeping errors with structured codes get a Swedish translation; + // otherwise pass-through. Match still proceeds — the user can re-book. + if (isBookkeepingError(err)) { + journalEntryError = getErrorMessage(err, { context: 'supplier_invoice' }) + } else { + journalEntryError = err instanceof Error ? err.message : 'Unknown error' + } + } + + const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100) + const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 + const isFullyPaid = newRemaining <= 0 + const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + + const { data: updatedRows, error: updateInvError } = await supabase + .from('supplier_invoices') + .update({ + status: newStatus, + remaining_amount: newRemaining, + paid_amount: newPaidAmount, + paid_at: isFullyPaid ? now : null, + payment_journal_entry_id: journalEntryId, + transaction_id: transactionId, + }) + .eq('id', supplier_invoice_id) + .in('status', ['registered', 'approved', 'partially_paid']) + .select('id') + + if (updateInvError) { + txLog.error('failed to update supplier invoice', updateInvError) + return errorResponse(updateInvError, txLog, { requestId }) + } + + if (!updatedRows || updatedRows.length === 0) { + return errorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, { requestId }) + } + + const { error: paymentInsertError } = await supabase + .from('supplier_invoice_payments') + .insert({ + user_id: user.id, + company_id: companyId, + supplier_invoice_id, + payment_date: transaction.date, + amount: paymentAmount, + currency: invoice.currency, + journal_entry_id: journalEntryId, + transaction_id: transactionId, + }) + + if (paymentInsertError) { + if (paymentInsertError.code === '23505') { + return errorResponseFromCode('MATCH_SI_DUPLICATE_PAYMENT', txLog, { requestId }) + } + txLog.error('failed to record supplier invoice payment', paymentInsertError) + return errorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, { requestId }) + } + + const { error: updateTxError } = await supabase + .from('transactions') + .update({ + supplier_invoice_id, + journal_entry_id: journalEntryId, + is_business: true, + }) + .eq('id', transactionId) + + if (updateTxError) { + txLog.error('failed to link transaction to supplier invoice', updateTxError) + return errorResponseFromCode('MATCH_SI_LINK_TX_FAILED', txLog, { requestId }) + } + + logMatchEvent(supabase, user.id, transactionId, 'matched', { + supplierInvoiceId: supplier_invoice_id, + matchConfidence: 1.0, + matchMethod: 'manual_confirm', + newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining }, + }) + + try { + eventBus.emit({ + type: 'supplier_invoice.match_confirmed', + payload: { + supplierInvoice: invoice as SupplierInvoice, + transaction: transaction as Transaction, + userId: user.id, + companyId, + }, + }) + } catch (err) { + txLog.warn('supplier_invoice.match_confirmed event emission failed', err as Error) + } + + if (journalEntryError) { + txLog.warn('supplier invoice match recorded but payment JE failed', { + message: journalEntryError, + }) + } + + return NextResponse.json({ + success: true, + invoice_status: newStatus, paid_amount: newPaidAmount, - paid_at: isFullyPaid ? now : null, - payment_journal_entry_id: journalEntryId, - transaction_id: transactionId, - }) - .eq('id', supplier_invoice_id) - .in('status', ['registered', 'approved', 'partially_paid']) - .select('id') - - if (updateInvError) { - return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 }) - } - - if (!updatedRows || updatedRows.length === 0) { - return NextResponse.json( - { error: 'Supplier invoice has already been fully paid or is no longer matchable' }, - { status: 409 } - ) - } - - // Record payment — catch unique constraint violation - const { error: paymentInsertError } = await supabase - .from('supplier_invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - supplier_invoice_id, - payment_date: transaction.date, - amount: paymentAmount, - currency: invoice.currency, + remaining_amount: newRemaining, journal_entry_id: journalEntryId, - transaction_id: transactionId, + ...(journalEntryError ? { journal_entry_error: journalEntryError } : {}), }) - - if (paymentInsertError) { - if (paymentInsertError.code === '23505') { - return NextResponse.json( - { error: 'This transaction is already matched to this supplier invoice' }, - { status: 409 } - ) - } - console.error('Failed to record supplier invoice payment:', paymentInsertError) - return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 }) - } - - // Update transaction - const { error: updateTxError } = await supabase - .from('transactions') - .update({ - supplier_invoice_id, - journal_entry_id: journalEntryId, - is_business: true, - }) - .eq('id', transactionId) - - if (updateTxError) { - return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 }) - } - - // Log the match event and emit event - logMatchEvent(supabase, user.id, transactionId, 'matched', { - supplierInvoiceId: supplier_invoice_id, - matchConfidence: 1.0, - matchMethod: 'manual_confirm', - newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining }, - }) - - try { - eventBus.emit({ - type: 'supplier_invoice.match_confirmed', - payload: { - supplierInvoice: invoice as SupplierInvoice, - transaction: transaction as Transaction, - userId: user.id, - companyId, - }, - }) - } catch { - // Event emission is non-critical - } - - return NextResponse.json({ - success: true, - invoice_status: newStatus, - paid_amount: newPaidAmount, - remaining_amount: newRemaining, - journal_entry_id: journalEntryId, - }) -} + }, + { requireWrite: true }, +) diff --git a/eslint.config.mjs b/eslint.config.mjs index df5b4e42..f7e9c48c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,9 +14,28 @@ const eslintConfig = defineConfig([ }], }, }, - // Override default ignores of eslint-config-next. + // No raw console.* in lib/ or app/api/. Use createLogger from @/lib/logger + // so log lines carry requestId + structured context. lib/logger.ts and + // app/api/log/route.ts are the two intentional exemptions because they ARE + // the logger plumbing. + { + files: ["lib/**/*.ts", "lib/**/*.tsx", "app/api/**/*.ts", "app/api/**/*.tsx"], + ignores: [ + "lib/logger.ts", + "app/api/log/route.ts", + // Test files have legitimate console use for assertions / debugging. + "**/__tests__/**", + "**/*.test.ts", + "**/*.bench.test.ts", + "**/*.pg.test.ts", + ], + rules: { + // warn (not error) until the remaining ~20 routes/lib files migrate. + // Flip to "error" once the count drops to zero so the floor is enforced. + "no-console": "warn", + }, + }, globalIgnores([ - // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 458445d6..f26f4a32 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -24,6 +24,11 @@ import { BAS_REFERENCE, getBASReference } from '@/lib/bookkeeping/bas-reference' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { FortnoxClient } from '@/lib/providers/fortnox/client' import type { ProviderName } from '@/lib/providers/types' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { classifyProviderError } from '@/lib/providers/with-provider-call' +import { createLogger } from '@/lib/logger' + +const moduleLog = createLogger('extensions/arcim-migration') /** Fiscal years we support importing — older data is not needed */ const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026]) @@ -133,10 +138,10 @@ export const arcimMigrationExtension: Extension = { }, }) } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to fetch status' }, - { status: 500 } - ) + moduleLog.error('arcim status failed', error as Error, { companyId }) + return errorResponseFromCode('PROVIDER_STATUS_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) } }, }, @@ -163,12 +168,16 @@ export const arcimMigrationExtension: Extension = { } if (!provider) { - return NextResponse.json({ error: 'provider is required' }, { status: 400 }) + return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { + details: { field: 'provider', reason: 'required' }, + }) } const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider) if (!providerInfo) { - return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }) + return errorResponseFromCode('PROVIDER_INVALID', moduleLog, { + details: { provider }, + }) } try { @@ -263,11 +272,10 @@ export const arcimMigrationExtension: Extension = { }) } } catch (error) { - log.error('Failed to create consent:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to connect' }, - { status: 500 } - ) + log.error('arcim connect failed', error as Error, { provider }) + return errorResponseFromCode('PROVIDER_CONNECT_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) } }, }, @@ -293,36 +301,32 @@ export const arcimMigrationExtension: Extension = { } if (!consentId || !provider) { - return NextResponse.json( - { error: 'consentId and provider are required' }, - { status: 400 } - ) + return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { + details: { fields: ['consentId', 'provider'], reason: 'required' }, + }) } // BL uses server-side client credentials — only needs companyId if (provider !== 'bjornlunden' && !apiToken) { - return NextResponse.json( - { error: 'apiToken is required for this provider' }, - { status: 400 } - ) + return errorResponseFromCode('PROVIDER_TOKEN_REQUIRED', moduleLog, { + details: { provider }, + }) } if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) { - return NextResponse.json( - { error: 'companyId is required for this provider' }, - { status: 400 } - ) + return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, { + details: { provider }, + }) } try { await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId) return NextResponse.json({ success: true, consentId }) } catch (error) { - log.error('Submit token error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to submit token' }, - { status: 500 } - ) + log.error('arcim submit-token failed', error as Error, { provider }) + return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) } }, }, @@ -470,16 +474,17 @@ export const arcimMigrationExtension: Extension = { const consentId = url.searchParams.get('consentId') if (!consentId) { - return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) + return errorResponseFromCode('VALIDATION_ERROR', moduleLog, { + details: { field: 'consentId', reason: 'required' }, + }) } try { const consent = await getConsent(consentId) if (consent.status !== 0 && consent.status !== 1) { - return NextResponse.json( - { error: 'Consent is not ready. Complete authentication first.' }, - { status: 400 } - ) + return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, { + details: { consentId, status: consent.status }, + }) } // Resolve consent to get access token @@ -562,11 +567,16 @@ export const arcimMigrationExtension: Extension = { hasSieData: (sieImportCount ?? 0) > 0, }) } catch (error) { - log.error('Preview error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Preview failed' }, - { status: 500 } - ) + log.error('arcim preview failed', error as Error) + // Classify HTTP failures into typed codes so the toast can suggest + // reconnect / retry instead of a generic "preview failed". + const classified = classifyProviderError(error) + return errorResponseFromCode(classified ?? 'PROVIDER_PREVIEW_FAILED', moduleLog, { + details: { + reason: error instanceof Error ? error.message : 'unknown', + classified: classified ?? 'unclassified', + }, + }) } }, }, @@ -599,10 +609,9 @@ export const arcimMigrationExtension: Extension = { const provider = resolved.consent.provider as ProviderName if (provider !== 'fortnox') { - return NextResponse.json( - { error: `SIE export is currently only supported for Fortnox. Provider: ${provider}` }, - { status: 400 } - ) + return errorResponseFromCode('PROVIDER_SIE_ONLY_FORTNOX', moduleLog, { + details: { provider }, + }) } // Fetch financial years from Fortnox @@ -623,7 +632,7 @@ export const arcimMigrationExtension: Extension = { }) if (allowedYears.length === 0) { - return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 }) + return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog) } // Fetch SIE type 4 for each allowed year @@ -646,7 +655,7 @@ export const arcimMigrationExtension: Extension = { } if (sieFiles.length === 0) { - return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 }) + return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog) } // Parse most recent file for preview/validation @@ -742,11 +751,14 @@ export const arcimMigrationExtension: Extension = { basAccounts: BAS_REFERENCE, }) } catch (error) { - log.error('SIE data fetch error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to fetch SIE data' }, - { status: 500 } - ) + log.error('arcim sie-data fetch failed', error as Error) + const classified = classifyProviderError(error) + return errorResponseFromCode(classified ?? 'PROVIDER_SIE_FETCH_FAILED', moduleLog, { + details: { + reason: error instanceof Error ? error.message : 'unknown', + classified: classified ?? 'unclassified', + }, + }) } }, }, @@ -903,11 +915,14 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json(result) } catch (error) { - log.error('SIE import failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'SIE import failed' }, - { status: 500 } - ) + log.error('arcim sie import failed', error as Error) + const classified = classifyProviderError(error) + return errorResponseFromCode(classified ?? 'SIE_IMPORT_UNEXPECTED', moduleLog, { + details: { + reason: error instanceof Error ? error.message : 'unknown', + classified: classified ?? 'unclassified', + }, + }) } }, }, @@ -950,10 +965,9 @@ export const arcimMigrationExtension: Extension = { try { const consent = await getConsent(consentId) if (consent.status !== 0 && consent.status !== 1) { - return NextResponse.json( - { error: 'Consent is not ready' }, - { status: 400 } - ) + return errorResponseFromCode('PROVIDER_CONSENT_NOT_READY', moduleLog, { + details: { consentId, status: consent.status }, + }) } log.info(`Starting migration for user ${user.id} from ${consent.provider}`) @@ -977,11 +991,14 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ success: true, results }) } catch (error) { - log.error('Migration failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Migration failed' }, - { status: 500 } - ) + log.error('arcim migration failed', error as Error) + const classified = classifyProviderError(error) + return errorResponseFromCode(classified ?? 'PROVIDER_MIGRATE_FAILED', moduleLog, { + details: { + reason: error instanceof Error ? error.message : 'unknown', + classified: classified ?? 'unclassified', + }, + }) } }, }, @@ -1013,17 +1030,17 @@ export const arcimMigrationExtension: Extension = { .single() if (!consent) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog) } try { await acceptConsent(consentId) return NextResponse.json({ success: true }) } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to accept consent' }, - { status: 500 } - ) + moduleLog.error('arcim accept failed', error as Error, { consentId }) + return errorResponseFromCode('PROVIDER_ACCEPT_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) } }, }, @@ -1057,7 +1074,7 @@ export const arcimMigrationExtension: Extension = { .single() if (!consent) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog) } try { @@ -1070,11 +1087,10 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ success: true }) } catch (error) { - log.error('Disconnect error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Disconnect failed' }, - { status: 500 } - ) + log.error('arcim disconnect failed', error as Error, { consentId }) + return errorResponseFromCode('PROVIDER_DISCONNECT_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) } }, }, diff --git a/extensions/general/mcp-server/__tests__/create-transactions.test.ts b/extensions/general/mcp-server/__tests__/create-transactions.test.ts new file mode 100644 index 00000000..767a6c88 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/create-transactions.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { tools } from '../server' + +const tool = tools.find((t) => t.name === 'gnubok_create_transactions')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('gnubok_create_transactions', () => { + it('is registered with a stage-style outputSchema', () => { + expect(tool).toBeDefined() + const schema = tool.outputSchema as Record + expect(schema.type).toBe('object') + expect((schema.properties as Record).operations).toBeDefined() + }) + + it('stages one pending_operation per input item and returns operation ids', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // first insert + enqueue({ data: { id: 'op-2' }, error: null }) // second insert + + const result = (await tool.execute( + { + transactions: [ + { date: '2026-05-01', amount: 100, description: 'Inflow', external_id: 'rec1' }, + { date: '2026-05-02', amount: -50, description: 'Outflow', currency: 'EUR' }, + ], + }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { staged_count: number; operations: Array<{ operation_id: string; risk_level: string }> } + + expect(result.staged_count).toBe(2) + expect(result.operations).toHaveLength(2) + expect(result.operations[0].operation_id).toBe('op-1') + expect(result.operations[1].operation_id).toBe('op-2') + expect(result.operations[0].risk_level).toBe('medium') + }) + + it('rejects empty arrays', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute({ transactions: [] }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/non-empty array/) + }) + + it('rejects more than 10 transactions per call', async () => { + const { supabase } = createQueuedMockSupabase() + const items = Array.from({ length: 11 }, (_, i) => ({ + date: '2026-05-01', + amount: i, + description: `tx ${i}`, + })) + await expect( + tool.execute({ transactions: items }, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/per-call limit of 10/) + }) + + it('rejects items with malformed dates', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute( + { + transactions: [{ date: '01/05/2026', amount: 1, description: 'x' }], + }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/YYYY-MM-DD/) + }) + + it('rejects items with non-finite amounts', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute( + { + transactions: [{ date: '2026-05-01', amount: 'NaN', description: 'x' }], + }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/finite number/) + }) + + it('rejects items with empty descriptions', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool.execute( + { + transactions: [{ date: '2026-05-01', amount: 1, description: ' ' }], + }, + 'company-1', + 'user-1', + supabase as never + ) + ).rejects.toThrow(/description is required/) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 208102cc..11786f3f 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -928,6 +928,115 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_create_transactions', + description: 'Stage one or more transactions for the user to approve. Each item creates a separate pending operation that the user confirms or rejects in the web app. Useful for ingesting rows from external sources (Airtable, CSVs, etc.). Max 10 per call.', + outputSchema: { + type: 'object', + properties: { + staged_count: { type: 'number', description: 'Number of items successfully staged.' }, + operations: { + type: 'array', + items: STAGED_OPERATION_SCHEMA, + description: 'One staged-operation result per input item, in the same order.', + }, + }, + required: ['staged_count', 'operations'], + }, + inputSchema: { + type: 'object', + properties: { + transactions: { + type: 'array', + minItems: 1, + maxItems: 10, + description: 'Up to 10 transactions to stage. Each becomes its own pending operation.', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'Transaction date (YYYY-MM-DD).' }, + amount: { type: 'number', description: 'Positive = income, negative = expense.' }, + description: { type: 'string', description: 'Free-text description shown in /transactions.' }, + currency: { type: 'string', description: 'ISO 4217 code. Default SEK.' }, + bank_connection_id: { type: 'string', description: 'Optional UUID of a bank_connections row to associate with.' }, + external_id: { type: 'string', description: 'Optional external reference (e.g., Airtable record ID). Shown in the preview; the DB enforces uniqueness per user, so the second commit of the same external_id will fail at approval.' }, + }, + required: ['date', 'amount', 'description'], + }, + }, + }, + required: ['transactions'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const items = args.transactions as Array> | undefined + if (!Array.isArray(items) || items.length === 0) { + throw new Error('transactions must be a non-empty array.') + } + if (items.length > 10) { + throw new Error('transactions exceeds the per-call limit of 10. Split into multiple calls.') + } + + const operations = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + const date = item.date as string + const amount = Number(item.amount) + const description = ((item.description as string) ?? '').trim() + const currency = ((item.currency as string) || 'SEK').toUpperCase() + const bankConnectionId = (item.bank_connection_id as string) || null + const externalId = (item.external_id as string) || null + + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error(`transactions[${i}].date must be in YYYY-MM-DD format.`) + } + if (!Number.isFinite(amount)) { + throw new Error(`transactions[${i}].amount must be a finite number.`) + } + if (!description) { + throw new Error(`transactions[${i}].description is required.`) + } + + const params = { + date, + amount, + description, + currency, + bank_connection_id: bankConnectionId, + external_id: externalId, + } + + const sign = amount >= 0 ? '+' : '' + const titleSuffix = externalId ? ` [${externalId}]` : '' + const title = `Ny transaktion: ${description} ${sign}${amount} ${currency}${titleSuffix}` + + const staged = await stagePendingOperation( + supabase, companyId, userId, 'create_transaction', + title, + params, + params, // params ARE the preview + actor, + { + description: 'Once approved, the transaction lands in /transactions as uncategorized. Use gnubok_categorize_transaction to book it.', + tool: 'gnubok_categorize_transaction', + } + ) + + operations.push(staged) + } + + return { + staged_count: operations.length, + operations, + } + }, + }, + { name: 'gnubok_list_uncategorized_transactions', description: 'List bank transactions with no journal entry yet, newest first. Paginated.', diff --git a/lib/__tests__/logger.test.ts b/lib/__tests__/logger.test.ts new file mode 100644 index 00000000..21ba116b --- /dev/null +++ b/lib/__tests__/logger.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { createTestLogger } from '../logger' + +describe('logger', () => { + it('emits records with module + msg + level + ts', () => { + const sink: any[] = [] + const log = createTestLogger('test/module', sink) + log.info('hello') + + expect(sink).toHaveLength(1) + expect(sink[0]).toMatchObject({ + level: 'info', + module: 'test/module', + msg: 'hello', + }) + expect(typeof sink[0].ts).toBe('string') + }) + + it('merges base context into every record', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink, { requestId: 'req_1' }) + log.info('hi') + expect(sink[0].requestId).toBe('req_1') + }) + + it('child() returns a logger that merges extra context', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink, { requestId: 'req_1' }) + const child = log.child({ companyId: 'co_1', userId: 'u_1' }) + child.warn('oops') + expect(sink[0]).toMatchObject({ + requestId: 'req_1', + companyId: 'co_1', + userId: 'u_1', + }) + }) + + it('treats Error args as the err field with name/message/code', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink) + const err = new Error('boom') + ;(err as any).code = '23505' + log.error('insert failed', err) + + expect(sink[0].err).toMatchObject({ name: 'Error', message: 'boom', code: '23505' }) + }) + + it('merges plain-object args into context', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink) + log.info('done', { durationMs: 42, status: 200 }) + expect(sink[0]).toMatchObject({ durationMs: 42, status: 200 }) + }) + + it('redacts sensitive keys recursively', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink) + log.info('login', { + user: 'alice', + headers: { authorization: 'Bearer secret', cookie: 'sess=xxx' }, + payload: { password: 'hunter2', token: 'tok' }, + }) + const rec = sink[0] + expect(rec.headers.authorization).toBe('[REDACTED]') + expect(rec.headers.cookie).toBe('[REDACTED]') + expect(rec.payload.password).toBe('[REDACTED]') + expect(rec.payload.token).toBe('[REDACTED]') + expect(rec.user).toBe('alice') + }) + + it('redacts personnummer-shaped strings while preserving UUIDs', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink) + log.info('processing for 800101-1234', { uuid: '57484518-3409-4b29-9d23-5d22f08bda63' }) + expect(sink[0].msg).toBe('[REDACTED]') + expect(sink[0].uuid).toBe('57484518-3409-4b29-9d23-5d22f08bda63') + }) + + it('routes non-object, non-Error args into details', () => { + const sink: any[] = [] + const log = createTestLogger('m', sink) + log.warn('legacy', 'string arg', 42) + expect(sink[0].details).toEqual(['string arg', 42]) + }) +}) diff --git a/lib/api/validate.ts b/lib/api/validate.ts index d4c984b8..48d5519c 100644 --- a/lib/api/validate.ts +++ b/lib/api/validate.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { NextResponse } from 'next/server' +import type { Logger } from '@/lib/logger' export interface ValidationSuccess { success: true @@ -13,6 +14,28 @@ export interface ValidationFailure { export type ValidationResult = ValidationSuccess | ValidationFailure +interface ValidationOptions { + /** Optional logger; when present, validation failures are logged at warn level. */ + log?: Logger + /** Identifier for the operation/route being validated, included in the log line. */ + operation?: string +} + +function logIssues( + options: ValidationOptions | undefined, + kind: 'body' | 'query' | 'json', + issues: Array<{ field: string; message: string; code: string }> | string, +) { + if (!options?.log) return + options.log.warn('validation failed', { + operation: options.operation, + kind, + ...(typeof issues === 'string' + ? { reason: issues } + : { issueCount: issues.length, issues }), + }) +} + /** * Validate a request body against a Zod schema. * @@ -29,11 +52,13 @@ export type ValidationResult = ValidationSuccess | ValidationFailure export async function validateBody( request: Request, schema: z.ZodType, + options?: ValidationOptions, ): Promise> { let body: unknown try { body = await request.json() } catch { + logIssues(options, 'json', 'Invalid JSON in request body') return { success: false, response: NextResponse.json( @@ -55,6 +80,8 @@ export async function validateBody( code: issue.code, })) + logIssues(options, 'body', errors) + return { success: false, response: NextResponse.json( @@ -84,6 +111,7 @@ export async function validateBody( export function validateQuery( request: Request, schema: z.ZodType, + options?: ValidationOptions, ): ValidationResult { const url = new URL(request.url) const raw = Object.fromEntries(url.searchParams.entries()) @@ -97,6 +125,8 @@ export function validateQuery( code: issue.code, })) + logIssues(options, 'query', errors) + return { success: false, response: NextResponse.json( diff --git a/lib/api/with-cron-context.ts b/lib/api/with-cron-context.ts new file mode 100644 index 00000000..8e2ea09d --- /dev/null +++ b/lib/api/with-cron-context.ts @@ -0,0 +1,129 @@ +/** + * Sibling of withRouteContext for cron endpoints. + * + * - Verifies CRON_SECRET via verifyCronSecret(); returns the standard envelope + * on failure. + * - Generates a parent requestId so every per-item log line for a single run + * shares a correlation id you can grep for in Vercel logs. + * - Provides a `forEach` helper that runs the iteratee in an isolated try/catch + * per item and logs the outcome at info/error level. A single failing item + * never aborts the run. + * + * Usage: + * export const GET = withCronContext('cron.invoice-reminders', async (ctx) => { + * const reminders = await loadDueReminders() + * const summary = await ctx.forEach('reminder', reminders, async (item, itemCtx) => { + * await sendReminder(item) + * }) + * return NextResponse.json({ data: summary }) + * }) + */ + +import { NextResponse } from 'next/server' +import { verifyCronSecret } from '@/lib/auth/cron' +import { createLogger, type Logger } from '@/lib/logger' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +export interface CronItemContext { + /** Per-item requestId, child of the run's parent requestId. */ + requestId: string + log: Logger + parentRequestId: string +} + +interface CronForEachResult { + total: number + succeeded: number + failed: number + failures: Array<{ index: number; error: string }> +} + +export interface CronContext { + requestId: string + log: Logger + /** + * Iterate items with isolated try/catch + structured per-item logs. The + * returned summary is suitable to ship in the response body so an operator + * can see how many succeeded/failed at a glance. + */ + forEach( + label: string, + items: T[], + iteratee: (item: T, itemCtx: CronItemContext) => Promise, + ): Promise +} + +type CronHandler = (request: Request, ctx: CronContext) => Promise + +function generateRequestId(prefix: 'cron' | 'cron_item' = 'cron'): string { + return `${prefix}_${crypto.randomUUID()}` +} + +export function withCronContext( + operation: string, + handler: CronHandler, +): (request: Request) => Promise { + return async function wrapped(request: Request): Promise { + const requestId = generateRequestId('cron') + const start = Date.now() + const log = createLogger(`cron/${operation}`, { requestId, operation }) + + const authError = verifyCronSecret(request) + if (authError) { + log.warn('cron auth failed') + return errorResponseFromCode('UNAUTHORIZED', log, { requestId }) + } + + log.info('cron run started') + + const forEach: CronContext['forEach'] = async (label, items, iteratee) => { + const result: CronForEachResult = { + total: items.length, + succeeded: 0, + failed: 0, + failures: [], + } + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const itemRequestId = generateRequestId('cron_item') + const itemLog = log.child({ itemRequestId, itemIndex: i, itemLabel: label }) + const itemCtx: CronItemContext = { + requestId: itemRequestId, + log: itemLog, + parentRequestId: requestId, + } + + try { + await iteratee(item, itemCtx) + result.succeeded++ + itemLog.info('cron item ok') + } catch (err) { + result.failed++ + const errorMessage = err instanceof Error ? err.message : String(err) + result.failures.push({ index: i, error: errorMessage }) + itemLog.error('cron item failed', err as Error) + } + } + + return result + } + + const ctx: CronContext = { requestId, log, forEach } + + try { + const response = await handler(request, ctx) + if (response instanceof Response && !response.headers.get('X-Request-Id')) { + response.headers.set('X-Request-Id', requestId) + } + log.info('cron run completed', { + durationMs: Date.now() - start, + status: response.status, + }) + return response + } catch (err) { + log.error('cron run failed', err as Error, { durationMs: Date.now() - start }) + return errorResponse(err, log, { requestId }) + } + } +} diff --git a/lib/api/with-route-context.ts b/lib/api/with-route-context.ts new file mode 100644 index 00000000..e3800438 --- /dev/null +++ b/lib/api/with-route-context.ts @@ -0,0 +1,157 @@ +/** + * Single wrapper that gives every API route the same shape: + * + * - generates a request id (`req_`) and threads it through the logger + * - resolves auth via requireAuth() and (by default) the active companyId + * - emits one structured `info` log on completion with duration + * - converts any thrown value into the canonical error envelope via + * errorResponse(); the request id appears in the response body and the + * X-Request-Id response header + * + * Usage: + * export const POST = withRouteContext('invoice.send', async (req, ctx) => { + * // ctx.requestId, ctx.log, ctx.user, ctx.supabase, ctx.companyId + * const result = await sendInvoice(...) + * return NextResponse.json({ data: result }) + * }) + * + * For dynamic routes the second parameter is the Next.js params promise: + * export const POST = withRouteContext('invoice.send', async (req, ctx, { params }) => { + * const { id } = await params + * ... + * }) + */ + +import type { SupabaseClient, User } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' +import { getActiveCompanyId } from '@/lib/company/context' +import { createLogger, type Logger } from '@/lib/logger' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +export interface RouteContext { + /** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id header. */ + requestId: string + /** Logger pre-bound with { requestId, userId, companyId, operation }. */ + log: Logger + /** Authenticated user. Always present — wrapper short-circuits with 401 otherwise. */ + user: User + /** Authenticated Supabase client (request-scoped, RLS active). */ + supabase: SupabaseClient + /** + * Resolved active company id. The wrapper short-circuits with + * COMPANY_CONTEXT_MISSING before invoking the handler when no company is + * resolved, so handlers can treat this as guaranteed non-null. Routes that + * need to opt out of the guarantee (e.g. onboarding) shouldn't use + * withRouteContext. + */ + companyId: string +} + +interface RouteContextOptions { + /** + * Defaults to false. When true, the wrapper rejects callers whose role in + * the active company is `viewer` (or who have no membership). Mirrors the + * existing requireWritePermission() helper so mutating routes can drop two + * lines of boilerplate. + */ + requireWrite?: boolean +} + +// Next.js 16 always passes a `{ params: Promise<...> }` second arg to route +// handlers — including on non-dynamic routes, where it's `Promise<{}>`. The +// generic defaults to that empty shape so static routes type-check without +// having to declare any params at the call site. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type DynamicParams = { params: Promise> } | { params: Promise<{}> } + +type RouteHandler

> }> = ( + request: Request, + ctx: RouteContext, + params: P, +) => Promise + +function generateRequestId(): string { + // crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js. + return `req_${crypto.randomUUID()}` +} + +export function withRouteContext

> }>( + operation: string, + handler: RouteHandler

, + options: RouteContextOptions = {}, +): (request: Request, params: P) => Promise { + const { requireWrite = false } = options + + return async function wrapped(request: Request, params: P): Promise { + const requestId = generateRequestId() + const start = Date.now() + const log = createLogger(`api/${operation}`, { requestId, operation }) + + try { + const auth = await requireAuth() + if (auth.error) { + log.warn('auth failed', { status: auth.error.status }) + // Pass through requireAuth's response unchanged for backwards-compat + // with existing route tests; only inject the request id header so + // support can still trace the request. + if (!auth.error.headers.get('X-Request-Id')) { + auth.error.headers.set('X-Request-Id', requestId) + } + return auth.error + } + + const { user, supabase } = auth + const userLog = log.child({ userId: user.id }) + + let companyId: string | null = null + try { + companyId = await getActiveCompanyId(supabase, user.id) + } catch (err) { + userLog.error('failed to resolve active company', err as Error) + } + + if (!companyId) { + return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId }) + } + + if (requireWrite) { + // Delegate to the existing helper so tests that already mock it + // continue to work. The helper returns its own 403 NextResponse; + // we wrap it in our request-id header for traceability. + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) { + userLog.warn('write permission denied') + if (!writeCheck.response.headers.get('X-Request-Id')) { + writeCheck.response.headers.set('X-Request-Id', requestId) + } + return writeCheck.response + } + } + + const ctx: RouteContext = { + requestId, + log: userLog.child({ companyId }), + user, + supabase, + companyId, + } + + const response = await handler(request, ctx, params) + + if (response instanceof Response && !response.headers.get('X-Request-Id')) { + response.headers.set('X-Request-Id', requestId) + } + + ctx.log.info('op completed', { + durationMs: Date.now() - start, + status: response.status, + }) + return response + } catch (err) { + log.error('op failed', err as Error, { durationMs: Date.now() - start }) + return errorResponse(err, log, { requestId }) + } + } +} diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 9178b7c6..d98574b5 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -49,6 +49,7 @@ export const SCOPE_GROUPS = [ export const TOOL_SCOPE_MAP: Record = { // Transactions gnubok_list_uncategorized_transactions: 'transactions:read', + gnubok_create_transactions: 'transactions:write', gnubok_categorize_transaction: 'transactions:write', gnubok_receipt_matcher: 'transactions:write', gnubok_get_counterparty_templates: 'transactions:read', diff --git a/lib/bookkeeping/__tests__/bas-reference.test.ts b/lib/bookkeeping/__tests__/bas-reference.test.ts index 5f2281d7..0e6bb07d 100644 --- a/lib/bookkeeping/__tests__/bas-reference.test.ts +++ b/lib/bookkeeping/__tests__/bas-reference.test.ts @@ -44,6 +44,14 @@ describe('BAS_REFERENCE data integrity', () => { expect(withoutDesc).toEqual([]) }) + it('no account name or description has a concatenated group header', () => { + const headerSuffix = /\s\d{2,}\s+[A-ZÅÄÖ]{2,}/ + const corrupted = BAS_REFERENCE.filter( + (a) => headerSuffix.test(a.account_name) || headerSuffix.test(a.description ?? ''), + ) + expect(corrupted).toEqual([]) + }) + it('every account has a valid account_type', () => { const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense', 'untaxed_reserves'] for (const account of BAS_REFERENCE) { diff --git a/lib/bookkeeping/bas-data/class-1-assets.ts b/lib/bookkeeping/bas-data/class-1-assets.ts index 612a4820..cdf2e820 100644 --- a/lib/bookkeeping/bas-data/class-1-assets.ts +++ b/lib/bookkeeping/bas-data/class-1-assets.ts @@ -322,12 +322,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1099', - account_name: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar 11 BYGGNADER OCH MARK', + account_name: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar', account_class: 1, account_group: '10', account_type: 'asset', normal_balance: 'debit', - description: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar 11 BYGGNADER OCH MARK', + description: 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar', sru_code: '7201', k2_excluded: false, }, @@ -487,12 +487,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1188', - account_name: 'Förskott för byggnader och mark 12 MASKINER RESPEKTIVE INVENTARIER', + account_name: 'Förskott för byggnader och mark', account_class: 1, account_group: '11', account_type: 'asset', normal_balance: 'debit', - description: 'Förskott för byggnader och mark 12 MASKINER RESPEKTIVE INVENTARIER', + description: 'Förskott för byggnader och mark', sru_code: '7202', k2_excluded: false, }, @@ -872,12 +872,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1299', - account_name: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar 13 FINANSIELLA ANLÄGGNINGSTILLGÅNGAR', + account_name: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar', account_class: 1, account_group: '12', account_type: 'asset', normal_balance: 'debit', - description: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar 13 FINANSIELLA ANLÄGGNINGSTILLGÅNGAR', + description: 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar', sru_code: '7202', k2_excluded: false, }, @@ -1389,12 +1389,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1389', - account_name: 'Ackumulerade nedskrivningar av andra långfristiga fordringar 14 LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', + account_name: 'Ackumulerade nedskrivningar av andra långfristiga fordringar', account_class: 1, account_group: '13', account_type: 'asset', normal_balance: 'debit', - description: 'Ackumulerade nedskrivningar av andra långfristiga fordringar 14 LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', + description: 'Ackumulerade nedskrivningar av andra långfristiga fordringar', sru_code: '7203', k2_excluded: false, }, @@ -1653,12 +1653,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1493', - account_name: 'Djur som klassificeras som omsättningstillgång 15 KUNDFORDRINGAR', + account_name: 'Djur som klassificeras som omsättningstillgång', account_class: 1, account_group: '14', account_type: 'asset', normal_balance: 'debit', - description: 'Djur som klassificeras som omsättningstillgång 15 KUNDFORDRINGAR', + description: 'Djur som klassificeras som omsättningstillgång', sru_code: '7210', k2_excluded: false, }, @@ -1939,12 +1939,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1573', - account_name: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i 16 ÖVRIGA KORTFRISTIGA FORDRINGAR', + account_name: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i', account_class: 1, account_group: '15', account_type: 'asset', normal_balance: 'debit', - description: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i 16 ÖVRIGA KORTFRISTIGA FORDRINGAR', + description: 'Kundfordringar hos övriga företag som det finns ett ägarintresse i', sru_code: '7211', k2_excluded: false, }, @@ -2258,12 +2258,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1690', - account_name: 'Fordringar för tecknat men ej inbetalt aktiekapital 17 FÖRUTBETALDA KOSTNADER OCH UPPLUPNA INTÄKTER', + account_name: 'Fordringar för tecknat men ej inbetalt aktiekapital', account_class: 1, account_group: '16', account_type: 'asset', normal_balance: 'debit', - description: 'Fordringar för tecknat men ej inbetalt aktiekapital 17 FÖRUTBETALDA KOSTNADER OCH UPPLUPNA INTÄKTER', + description: 'Fordringar för tecknat men ej inbetalt aktiekapital', sru_code: '7212', k2_excluded: false, }, @@ -2357,7 +2357,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1790', - account_name: 'Övriga förutbetalda kostnader och upplupna intäkter 18 KORTFRISTIGA PLACERINGAR', + account_name: 'Övriga förutbetalda kostnader och upplupna intäkter', account_class: 1, account_group: '17', account_type: 'asset', @@ -2445,12 +2445,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1890', - account_name: 'Nedskrivning av kortfristiga placeringar 19 KASSA OCH BANK', + account_name: 'Nedskrivning av kortfristiga placeringar', account_class: 1, account_group: '18', account_type: 'asset', normal_balance: 'debit', - description: 'Nedskrivning av kortfristiga placeringar 19 KASSA OCH BANK', + description: 'Nedskrivning av kortfristiga placeringar', sru_code: '7212', k2_excluded: false, }, @@ -2621,12 +2621,12 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '1990', - account_name: 'Redovisningsmedel 20 EGET KAPITAL', + account_name: 'Redovisningsmedel', account_class: 1, account_group: '19', account_type: 'asset', normal_balance: 'debit', - description: 'Redovisningsmedel 20 EGET KAPITAL', + description: 'Redovisningsmedel', sru_code: '7212', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts index f55c6ee3..c62c7a5e 100644 --- a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts +++ b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts @@ -608,7 +608,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2099', - account_name: 'Årets resultat 21 OBESKATTADE RESERVER', + account_name: 'Årets resultat', account_class: 2, account_group: '20', account_type: 'equity', @@ -872,12 +872,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2199', - account_name: 'Övriga obeskattade reserver 22 AVSÄTTNINGAR', + account_name: 'Övriga obeskattade reserver', account_class: 2, account_group: '21', account_type: 'untaxed_reserves', normal_balance: 'credit', - description: 'Övriga obeskattade reserver 22 AVSÄTTNINGAR', + description: 'Övriga obeskattade reserver', sru_code: '7230', k2_excluded: false, }, @@ -960,12 +960,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2290', - account_name: 'Övriga avsättningar 23 LÅNGFRISTIGA SKULDER', + account_name: 'Övriga avsättningar', account_class: 2, account_group: '22', account_type: 'liability', normal_balance: 'credit', - description: 'Övriga avsättningar 23 LÅNGFRISTIGA SKULDER', + description: 'Övriga avsättningar', sru_code: '7230', k2_excluded: false, }, @@ -1279,12 +1279,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2399', - account_name: 'Övriga långfristiga skulder 24 KORTFRISTIGA SKULDER TILL KREDITINSTITUT, KUNDER OCH LEVERANTÖRER', + account_name: 'Övriga långfristiga skulder', account_class: 2, account_group: '23', account_type: 'liability', normal_balance: 'credit', - description: 'Övriga långfristiga skulder 24 KORTFRISTIGA SKULDER TILL KREDITINSTITUT, KUNDER OCH LEVERANTÖRER', + description: 'Övriga långfristiga skulder', sru_code: '7230', k2_excluded: false, }, @@ -1620,12 +1620,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2499', - account_name: 'Andra övriga kortfristiga skulder 25 SKATTESKULDER', + account_name: 'Andra övriga kortfristiga skulder', account_class: 2, account_group: '24', account_type: 'liability', normal_balance: 'credit', - description: 'Andra övriga kortfristiga skulder 25 SKATTESKULDER', + description: 'Andra övriga kortfristiga skulder', sru_code: '7230', k2_excluded: false, }, @@ -1697,12 +1697,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2518', - account_name: 'Betald F-skatt 26 MOMS OCH PUNKTSKATTER', + account_name: 'Betald F-skatt', account_class: 2, account_group: '25', account_type: 'liability', normal_balance: 'credit', - description: 'Betald F-skatt 26 MOMS OCH PUNKTSKATTER', + description: 'Betald F-skatt', sru_code: '7231', k2_excluded: false, }, @@ -2082,12 +2082,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2670', - account_name: 'Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG', + account_name: 'Utgående moms på försäljning inom EU, OSS', account_class: 2, account_group: '26', account_type: 'liability', normal_balance: 'credit', - description: 'Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG', + description: 'Utgående moms på försäljning inom EU, OSS', sru_code: '7231', k2_excluded: false, }, @@ -2258,12 +2258,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2799', - account_name: 'Övriga löneavdrag 28 ÖVRIGA KORTFRISTIGA SKULDER', + account_name: 'Övriga löneavdrag', account_class: 2, account_group: '27', account_type: 'liability', normal_balance: 'credit', - description: 'Övriga löneavdrag 28 ÖVRIGA KORTFRISTIGA SKULDER', + description: 'Övriga löneavdrag', sru_code: '7231', k2_excluded: false, }, @@ -2599,12 +2599,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2899', - account_name: 'Övriga kortfristiga skulder 29 UPPLUPNA KOSTNADER OCH FÖRUTBETALDA INTÄKTER', + account_name: 'Övriga kortfristiga skulder', account_class: 2, account_group: '28', account_type: 'liability', normal_balance: 'credit', - description: 'Övriga kortfristiga skulder 29 UPPLUPNA KOSTNADER OCH FÖRUTBETALDA INTÄKTER', + description: 'Övriga kortfristiga skulder', sru_code: '7231', k2_excluded: false, }, @@ -2907,12 +2907,12 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '2999', - account_name: 'OBS-konto 30 HUVUDINTÄKTER', + account_name: 'OBS-konto', account_class: 2, account_group: '29', account_type: 'liability', normal_balance: 'credit', - description: 'OBS-konto 30 HUVUDINTÄKTER', + description: 'OBS-konto', sru_code: '7231', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-3-revenue.ts b/lib/bookkeeping/bas-data/class-3-revenue.ts index d9f2ba81..925ea8e1 100644 --- a/lib/bookkeeping/bas-data/class-3-revenue.ts +++ b/lib/bookkeeping/bas-data/class-3-revenue.ts @@ -223,12 +223,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3404', - account_name: 'Egna uttag, momsfria 35 FAKTURERADE KOSTNADER', + account_name: 'Egna uttag, momsfria', account_class: 3, account_group: '34', account_type: 'revenue', normal_balance: 'credit', - description: 'Egna uttag, momsfria 35 FAKTURERADE KOSTNADER', + description: 'Egna uttag, momsfria', sru_code: '7310', k2_excluded: false, }, @@ -421,12 +421,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3590', - account_name: 'Övriga fakturerade kostnader 36 RÖRELSENS SIDOINTÄKTER', + account_name: 'Övriga fakturerade kostnader', account_class: 3, account_group: '35', account_type: 'revenue', normal_balance: 'credit', - description: 'Övriga fakturerade kostnader 36 RÖRELSENS SIDOINTÄKTER', + description: 'Övriga fakturerade kostnader', sru_code: '7310', k2_excluded: false, }, @@ -575,12 +575,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3690', - account_name: 'Övriga sidointäkter 37 INTÄKTSKORRIGERINGAR', + account_name: 'Övriga sidointäkter', account_class: 3, account_group: '36', account_type: 'revenue', normal_balance: 'credit', - description: 'Övriga sidointäkter 37 INTÄKTSKORRIGERINGAR', + description: 'Övriga sidointäkter', sru_code: '7310', k2_excluded: false, }, @@ -685,12 +685,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3790', - account_name: 'Övriga intäktskorrigeringar 38 AKTIVERAT ARBETE FÖR EGEN RÄKNING', + account_name: 'Övriga intäktskorrigeringar', account_class: 3, account_group: '37', account_type: 'revenue', normal_balance: 'debit', - description: 'Övriga intäktskorrigeringar 38 AKTIVERAT ARBETE FÖR EGEN RÄKNING', + description: 'Övriga intäktskorrigeringar', sru_code: '7310', k2_excluded: false, }, @@ -729,12 +729,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3870', - account_name: 'Aktiverat arbete (personal) 39 ÖVRIGA RÖRELSEINTÄKTER', + account_name: 'Aktiverat arbete (personal)', account_class: 3, account_group: '38', account_type: 'revenue', normal_balance: 'credit', - description: 'Aktiverat arbete (personal) 39 ÖVRIGA RÖRELSEINTÄKTER', + description: 'Aktiverat arbete (personal)', sru_code: '7310', k2_excluded: false, }, @@ -1092,12 +1092,12 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '3999', - account_name: 'Övriga rörelseintäkter 40 INKÖP AV HANDELSVAROR', + account_name: 'Övriga rörelseintäkter', account_class: 3, account_group: '39', account_type: 'revenue', normal_balance: 'credit', - description: 'Övriga rörelseintäkter 40 INKÖP AV HANDELSVAROR', + description: 'Övriga rörelseintäkter', sru_code: '7310', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-4-purchases.ts b/lib/bookkeeping/bas-data/class-4-purchases.ts index cf22ee84..3bbe8d0a 100644 --- a/lib/bookkeeping/bas-data/class-4-purchases.ts +++ b/lib/bookkeeping/bas-data/class-4-purchases.ts @@ -201,12 +201,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4099', - account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB', + account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror)', account_class: 4, account_group: '40', account_type: 'expense', normal_balance: 'credit', - description: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB', + description: 'Övriga reduktioner av inköpspriser (Handelsvaror)', sru_code: '7320', k2_excluded: false, }, @@ -245,12 +245,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4212', - account_name: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Sålda handelsvaror negativ VMB 25 %', account_class: 4, account_group: '42', account_type: 'expense', normal_balance: 'debit', - description: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Sålda handelsvaror negativ VMB 25 %', sru_code: '7320', k2_excluded: false, }, @@ -267,12 +267,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4310', - account_name: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Inköp av råvaror och material i Sverige', account_class: 4, account_group: '43', account_type: 'expense', normal_balance: 'debit', - description: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Inköp av råvaror och material i Sverige', sru_code: '7320', k2_excluded: false, }, @@ -366,12 +366,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4427', - account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms', account_class: 4, account_group: '44', account_type: 'expense', normal_balance: 'debit', - description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms', sru_code: '7320', k2_excluded: false, }, @@ -564,12 +564,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4547', - account_name: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Import av råvaror och material, 6 % moms', account_class: 4, account_group: '45', account_type: 'expense', normal_balance: 'debit', - description: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Import av råvaror och material, 6 % moms', sru_code: '7320', k2_excluded: false, }, @@ -597,12 +597,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4670', - account_name: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Inköp av legoarbeten', account_class: 4, account_group: '46', account_type: 'expense', normal_balance: 'debit', - description: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Inköp av legoarbeten', sru_code: '7320', k2_excluded: false, }, @@ -652,12 +652,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4739', - account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter)', account_class: 4, account_group: '47', account_type: 'expense', normal_balance: 'credit', - description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter)', sru_code: '7320', k2_excluded: false, }, @@ -718,12 +718,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4890', - account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', + account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter)', account_class: 4, account_group: '48', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', + description: 'Övriga produktionskostnader (Råvaror och förnödenheter)', sru_code: '7320', k2_excluded: false, }, @@ -905,12 +905,12 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '4988', - account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER', + account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror)', account_class: 4, account_group: '49', account_type: 'expense', normal_balance: 'credit', - description: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER', + description: 'Återföring av nedskrivning av värdepapper (Handelsvaror)', sru_code: '7320', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-5-external-expenses.ts b/lib/bookkeeping/bas-data/class-5-external-expenses.ts index c18e242d..77c4f1b0 100644 --- a/lib/bookkeeping/bas-data/class-5-external-expenses.ts +++ b/lib/bookkeeping/bas-data/class-5-external-expenses.ts @@ -190,7 +190,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5090', - account_name: 'Övriga lokalkostnader 51 FASTIGHETSKOSTNADER', + account_name: 'Övriga lokalkostnader', account_class: 5, account_group: '50', account_type: 'expense', @@ -410,12 +410,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5198', - account_name: 'Övriga fastighetskostnader 52 HYRA AV ANLÄGGNINGSTILLGÅNGAR', + account_name: 'Övriga fastighetskostnader', account_class: 5, account_group: '51', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga fastighetskostnader 52 HYRA AV ANLÄGGNINGSTILLGÅNGAR', + description: 'Övriga fastighetskostnader', sru_code: '7321', k2_excluded: false, }, @@ -465,12 +465,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5290', - account_name: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon 53 ENERGIKOSTNADER FÖR DRIFT (EJ RÅVAROR OCH FÖRNÖDENHETER)', + account_name: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon', account_class: 5, account_group: '52', account_type: 'expense', normal_balance: 'debit', - description: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon 53 ENERGIKOSTNADER FÖR DRIFT (EJ RÅVAROR OCH FÖRNÖDENHETER)', + description: 'Hyra av övriga anläggningstillgångar, ej datorer och fordon', sru_code: '7321', k2_excluded: false, }, @@ -575,12 +575,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5390', - account_name: 'Övriga energikostnader för drift (ej råvaror och förnödenheter) 54 FÖRBRUKNINGSINVENTARIER OCH FÖRBRUKNINGSMATERIAL', + account_name: 'Övriga energikostnader för drift (ej råvaror och förnödenheter)', account_class: 5, account_group: '53', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga energikostnader för drift (ej råvaror och förnödenheter) 54 FÖRBRUKNINGSINVENTARIER OCH FÖRBRUKNINGSMATERIAL', + description: 'Övriga energikostnader för drift (ej råvaror och förnödenheter)', sru_code: '7321', k2_excluded: false, }, @@ -674,12 +674,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5480', - account_name: 'Arbetskläder och skyddsmaterial 55 REPARATION OCH UNDERHÅLL', + account_name: 'Arbetskläder och skyddsmaterial', account_class: 5, account_group: '54', account_type: 'expense', normal_balance: 'debit', - description: 'Arbetskläder och skyddsmaterial 55 REPARATION OCH UNDERHÅLL', + description: 'Arbetskläder och skyddsmaterial', sru_code: '7321', k2_excluded: false, }, @@ -751,12 +751,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5590', - account_name: 'Övriga kostnader för reparation och underhåll 56 KOSTNADER FÖR TRANSPORTMEDEL', + account_name: 'Övriga kostnader för reparation och underhåll', account_class: 5, account_group: '55', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga kostnader för reparation och underhåll 56 KOSTNADER FÖR TRANSPORTMEDEL', + description: 'Övriga kostnader för reparation och underhåll', sru_code: '7321', k2_excluded: false, }, @@ -1345,12 +1345,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5699', - account_name: 'Övriga kostnader för övriga transportmedel 57 FRAKTER OCH TRANSPORTER', + account_name: 'Övriga kostnader för övriga transportmedel', account_class: 5, account_group: '56', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga kostnader för övriga transportmedel 57 FRAKTER OCH TRANSPORTER', + description: 'Övriga kostnader för övriga transportmedel', sru_code: '7321', k2_excluded: false, }, @@ -1455,12 +1455,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5790', - account_name: 'Övriga kostnader för frakter och transporter 58 RESEKOSTNADER', + account_name: 'Övriga kostnader för frakter och transporter', account_class: 5, account_group: '57', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga kostnader för frakter och transporter 58 RESEKOSTNADER', + description: 'Övriga kostnader för frakter och transporter', sru_code: '7321', k2_excluded: false, }, @@ -1532,12 +1532,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5890', - account_name: 'Övriga resekostnader 59 REKLAM OCH PR', + account_name: 'Övriga resekostnader', account_class: 5, account_group: '58', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga resekostnader 59 REKLAM OCH PR', + description: 'Övriga resekostnader', sru_code: '7321', k2_excluded: false, }, @@ -1664,12 +1664,12 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '5990', - account_name: 'Övriga kostnader för reklam och PR 60 ÖVRIGA FÖRSÄLJNINGSKOSTNADER', + account_name: 'Övriga kostnader för reklam och PR', account_class: 5, account_group: '59', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga kostnader för reklam och PR 60 ÖVRIGA FÖRSÄLJNINGSKOSTNADER', + description: 'Övriga kostnader för reklam och PR', sru_code: '7321', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-6-other-external.ts b/lib/bookkeeping/bas-data/class-6-other-external.ts index 3cfe8b52..6621d71d 100644 --- a/lib/bookkeeping/bas-data/class-6-other-external.ts +++ b/lib/bookkeeping/bas-data/class-6-other-external.ts @@ -201,12 +201,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6090', - account_name: 'Övriga försäljningskostnader 61 KONTORSMATERIAL OCH TRYCKSAKER', + account_name: 'Övriga försäljningskostnader', account_class: 6, account_group: '60', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga försäljningskostnader 61 KONTORSMATERIAL OCH TRYCKSAKER', + description: 'Övriga försäljningskostnader', sru_code: '7321', k2_excluded: false, }, @@ -234,7 +234,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6150', - account_name: 'Trycksaker 62 TELE, DATA OCH POST', + account_name: 'Trycksaker', account_class: 6, account_group: '61', account_type: 'expense', @@ -322,12 +322,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6290', - account_name: 'Övriga tele-, data- och postkostnader 63 FÖRETAGSFÖRSÄKRINGAR OCH ÖVRIGA RISKKOSTNADER', + account_name: 'Övriga tele-, data- och postkostnader', account_class: 6, account_group: '62', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga tele-, data- och postkostnader 63 FÖRETAGSFÖRSÄKRINGAR OCH ÖVRIGA RISKKOSTNADER', + description: 'Övriga tele-, data- och postkostnader', sru_code: '7321', k2_excluded: false, }, @@ -520,12 +520,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6392', - account_name: 'Övriga riskkostnader, ej avdragsgilla 64 FÖRVALTNINGSKOSTNADER', + account_name: 'Övriga riskkostnader, ej avdragsgilla', account_class: 6, account_group: '63', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga riskkostnader, ej avdragsgilla 64 FÖRVALTNINGSKOSTNADER', + description: 'Övriga riskkostnader, ej avdragsgilla', sru_code: '7321', k2_excluded: false, }, @@ -630,12 +630,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6490', - account_name: 'Övriga förvaltningskostnader 65 ÖVRIGA EXTERNA TJÄNSTER', + account_name: 'Övriga förvaltningskostnader', account_class: 6, account_group: '64', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga förvaltningskostnader 65 ÖVRIGA EXTERNA TJÄNSTER', + description: 'Övriga förvaltningskostnader', sru_code: '7321', k2_excluded: false, }, @@ -817,12 +817,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6590', - account_name: 'Övriga externa tjänster 67 SÄRSKILT FÖR IDEELLA FÖRENINGAR OCH STIFTELSER', + account_name: 'Övriga externa tjänster', account_class: 6, account_group: '65', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga externa tjänster 67 SÄRSKILT FÖR IDEELLA FÖRENINGAR OCH STIFTELSER', + description: 'Övriga externa tjänster', sru_code: '7321', k2_excluded: false, }, @@ -839,12 +839,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6710', - account_name: 'Lämnade bidrag 68 INHYRD PERSONAL', + account_name: 'Lämnade bidrag', account_class: 6, account_group: '67', account_type: 'expense', normal_balance: 'debit', - description: 'Lämnade bidrag 68 INHYRD PERSONAL', + description: 'Lämnade bidrag', sru_code: '7321', k2_excluded: false, }, @@ -949,12 +949,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6890', - account_name: 'Övrig inhyrd personal 69 ÖVRIGA EXTERNA KOSTNADER', + account_name: 'Övrig inhyrd personal', account_class: 6, account_group: '68', account_type: 'expense', normal_balance: 'debit', - description: 'Övrig inhyrd personal 69 ÖVRIGA EXTERNA KOSTNADER', + description: 'Övrig inhyrd personal', sru_code: '7321', k2_excluded: false, }, @@ -1147,12 +1147,12 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '6999', - account_name: 'Ingående moms, blandad verksamhet 70 LÖNER TILL KOLLEKTIVANSTÄLLDA', + account_name: 'Ingående moms, blandad verksamhet', account_class: 6, account_group: '69', account_type: 'expense', normal_balance: 'debit', - description: 'Ingående moms, blandad verksamhet 70 LÖNER TILL KOLLEKTIVANSTÄLLDA', + description: 'Ingående moms, blandad verksamhet', sru_code: '7330', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-7-personnel.ts b/lib/bookkeeping/bas-data/class-7-personnel.ts index 0e55b99d..d9a843af 100644 --- a/lib/bookkeeping/bas-data/class-7-personnel.ts +++ b/lib/bookkeeping/bas-data/class-7-personnel.ts @@ -212,7 +212,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7090', - account_name: 'Förändring av semesterlöneskuld 72 LÖNER TILL TJÄNSTEMÄN OCH FÖRETAGSLEDARE', + account_name: 'Förändring av semesterlöneskuld', account_class: 7, account_group: '70', account_type: 'expense', @@ -575,12 +575,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7292', - account_name: 'Förändring av semesterlöneskuld till företagsledare 73 KOSTNADSERSÄTTNINGAR OCH FÖRMÅNER', + account_name: 'Förändring av semesterlöneskuld till företagsledare', account_class: 7, account_group: '72', account_type: 'expense', normal_balance: 'debit', - description: 'Förändring av semesterlöneskuld till företagsledare 73 KOSTNADSERSÄTTNINGAR OCH FÖRMÅNER', + description: 'Förändring av semesterlöneskuld till företagsledare', sru_code: '7322', k2_excluded: false, }, @@ -960,12 +960,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7392', - account_name: 'Kostnad för förmån av hushållsnära tjänster 74 PENSIONSKOSTNADER', + account_name: 'Kostnad för förmån av hushållsnära tjänster', account_class: 7, account_group: '73', account_type: 'expense', normal_balance: 'debit', - description: 'Kostnad för förmån av hushållsnära tjänster 74 PENSIONSKOSTNADER', + description: 'Kostnad för förmån av hushållsnära tjänster', sru_code: '7322', k2_excluded: false, }, @@ -1125,12 +1125,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7490', - account_name: 'Övriga pensionskostnader 75 SOCIALA OCH ANDRA AVGIFTER ENLIGT LAG OCH AVTAL', + account_name: 'Övriga pensionskostnader', account_class: 7, account_group: '74', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga pensionskostnader 75 SOCIALA OCH ANDRA AVGIFTER ENLIGT LAG OCH AVTAL', + description: 'Övriga pensionskostnader', sru_code: '7322', k2_excluded: false, }, @@ -1411,12 +1411,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7590', - account_name: 'Övriga sociala och andra avgifter enligt lag och avtal 76 ÖVRIGA PERSONALKOSTNADER', + account_name: 'Övriga sociala och andra avgifter enligt lag och avtal', account_class: 7, account_group: '75', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga sociala och andra avgifter enligt lag och avtal 76 ÖVRIGA PERSONALKOSTNADER', + description: 'Övriga sociala och andra avgifter enligt lag och avtal', sru_code: '7322', k2_excluded: false, }, @@ -1609,12 +1609,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7699', - account_name: 'Övriga personalkostnader 77 NEDSKRIVNINGAR OCH ÅTERFÖRING AV NEDSKRIVNINGAR', + account_name: 'Övriga personalkostnader', account_class: 7, account_group: '76', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga personalkostnader 77 NEDSKRIVNINGAR OCH ÅTERFÖRING AV NEDSKRIVNINGAR', + description: 'Övriga personalkostnader', sru_code: '7322', k2_excluded: false, }, @@ -1763,12 +1763,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7790', - account_name: 'Återföring av nedskrivningar av vissa omsättningstillgångar 78 AVSKRIVNINGAR ENLIGT PLAN', + account_name: 'Återföring av nedskrivningar av vissa omsättningstillgångar', account_class: 7, account_group: '77', account_type: 'expense', normal_balance: 'credit', - description: 'Återföring av nedskrivningar av vissa omsättningstillgångar 78 AVSKRIVNINGAR ENLIGT PLAN', + description: 'Återföring av nedskrivningar av vissa omsättningstillgångar', sru_code: '7325', k2_excluded: false, }, @@ -1972,12 +1972,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7840', - account_name: 'Avskrivningar på förbättringsutgifter på annans fastighet 79 ÖVRIGA RÖRELSEKOSTNADER', + account_name: 'Avskrivningar på förbättringsutgifter på annans fastighet', account_class: 7, account_group: '78', account_type: 'expense', normal_balance: 'debit', - description: 'Avskrivningar på förbättringsutgifter på annans fastighet 79 ÖVRIGA RÖRELSEKOSTNADER', + description: 'Avskrivningar på förbättringsutgifter på annans fastighet', sru_code: '7325', k2_excluded: false, }, @@ -2049,12 +2049,12 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '7990', - account_name: 'Övriga rörelsekostnader 80 RESULTAT FRÅN ANDELAR I KONCERNFÖRETAG', + account_name: 'Övriga rörelsekostnader', account_class: 7, account_group: '79', account_type: 'expense', normal_balance: 'debit', - description: 'Övriga rörelsekostnader 80 RESULTAT FRÅN ANDELAR I KONCERNFÖRETAG', + description: 'Övriga rörelsekostnader', sru_code: '7360', k2_excluded: false, }, diff --git a/lib/bookkeeping/bas-data/class-8-financial.ts b/lib/bookkeeping/bas-data/class-8-financial.ts index a4e9b42e..8b21ea64 100644 --- a/lib/bookkeeping/bas-data/class-8-financial.ts +++ b/lib/bookkeeping/bas-data/class-8-financial.ts @@ -135,12 +135,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8087', - account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag 81 RESULTAT FRÅN ANDELAR I INTRESSEFÖRETAG OCH GEMENSAMT STYRDA FÖRETAG SAMT ÖVRIGA FÖRETAG SOM DET FINNS ETT ÄGARINTRESSE I', + account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag', account_class: 8, account_group: '80', account_type: 'revenue', normal_balance: 'credit', - description: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag 81 RESULTAT FRÅN ANDELAR I INTRESSEFÖRETAG OCH GEMENSAMT STYRDA FÖRETAG SAMT ÖVRIGA FÖRETAG SOM DET FINNS ETT ÄGARINTRESSE I', + description: 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag', sru_code: '7370', k2_excluded: false, }, @@ -454,12 +454,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8187', - account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i 82 RESULTAT FRÅN ÖVRIGA VÄRDEPAPPER OCH LÅNGFRISTIGA FORDRINGAR (ANLÄGGNINGSTILLGÅNGAR)', + account_name: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i', account_class: 8, account_group: '81', account_type: 'revenue', normal_balance: 'credit', - description: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i 82 RESULTAT FRÅN ÖVRIGA VÄRDEPAPPER OCH LÅNGFRISTIGA FORDRINGAR (ANLÄGGNINGSTILLGÅNGAR)', + description: 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i', sru_code: '7370', k2_excluded: false, }, @@ -795,12 +795,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8295', - account_name: 'Orealiserade värdeförändringar på derivatinstrument 83 ÖVRIGA RÄNTEINTÄKTER OCH LIKNANDE RESULTATPOSTER', + account_name: 'Orealiserade värdeförändringar på derivatinstrument', account_class: 8, account_group: '82', account_type: 'revenue', normal_balance: 'credit', - description: 'Orealiserade värdeförändringar på derivatinstrument 83 ÖVRIGA RÄNTEINTÄKTER OCH LIKNANDE RESULTATPOSTER', + description: 'Orealiserade värdeförändringar på derivatinstrument', sru_code: '7370', k2_excluded: true, }, @@ -1037,12 +1037,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8390', - account_name: 'Övriga finansiella intäkter 84 RÄNTEKOSTNADER OCH LIKNANDE RESULTATPOSTER', + account_name: 'Övriga finansiella intäkter', account_class: 8, account_group: '83', account_type: 'revenue', normal_balance: 'credit', - description: 'Övriga finansiella intäkter 84 RÄNTEKOSTNADER OCH LIKNANDE RESULTATPOSTER', + description: 'Övriga finansiella intäkter', sru_code: '7370', k2_excluded: false, }, @@ -1356,12 +1356,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8491', - account_name: 'Erhållet ackord på skulder till kreditinstitut m.m. 88 BOKSLUTSDISPOSITIONER', + account_name: 'Erhållet ackord på skulder till kreditinstitut m.m.', account_class: 8, account_group: '84', account_type: 'expense', normal_balance: 'credit', - description: 'Erhållet ackord på skulder till kreditinstitut m.m. 88 BOKSLUTSDISPOSITIONER', + description: 'Erhållet ackord på skulder till kreditinstitut m.m.', sru_code: '7323', k2_excluded: false, }, @@ -1587,12 +1587,12 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [ }, { account_number: '8899', - account_name: 'Övriga bokslutsdispositioner 89 SKATTER OCH ÅRETS RESULTAT', + account_name: 'Övriga bokslutsdispositioner', account_class: 8, account_group: '88', account_type: 'revenue', normal_balance: 'credit', - description: 'Övriga bokslutsdispositioner 89 SKATTER OCH ÅRETS RESULTAT', + description: 'Övriga bokslutsdispositioner', sru_code: '7380', k2_excluded: false, }, diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts index 2b3cfea8..9cf01ae1 100644 --- a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts +++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts @@ -123,11 +123,22 @@ describe('Supplier Invoice Core Handler', () => { }, }) - expect(consoleSpy).toHaveBeenCalledWith( - '[supplier-invoice-handler]', - 'Failed to create registration journal entry:', - expect.any(Error) - ) + // Logger emits a structured error line; assert the handler logged the + // failure with the right module prefix and an Error somewhere in the args. + const calls = consoleSpy.mock.calls + expect(calls.length).toBeGreaterThan(0) + expect(calls.some((c) => String(c[0]).includes('[supplier-invoice-handler]'))).toBe(true) + expect( + calls.some((c) => + c.some( + (arg) => + arg instanceof Error || + (typeof arg === 'object' && + arg !== null && + (arg as { message?: unknown }).message === 'No fiscal period'), + ), + ), + ).toBe(true) consoleSpy.mockRestore() }) diff --git a/lib/errors/__tests__/structured-errors.test.ts b/lib/errors/__tests__/structured-errors.test.ts new file mode 100644 index 00000000..a8b2ce9a --- /dev/null +++ b/lib/errors/__tests__/structured-errors.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest' +import { ZodError, z } from 'zod' +import { + errorResponse, + errorResponseFromCode, + type ErrorEnvelope, +} from '../get-structured-error' +import { getErrorEntry, listErrorCodes } from '../structured-errors' +import { + AccountsNotInChartError, + EntryDateOutsideFiscalPeriodError, + JournalEntryNotBalancedError, +} from '@/lib/bookkeeping/errors' + +const noopLogger = { + error: () => {}, +} + +async function readEnvelope(res: Response): Promise { + return (await res.json()) as ErrorEnvelope +} + +describe('structured-errors registry', () => { + it('has entries for the canonical generic codes', () => { + for (const code of [ + 'INTERNAL_ERROR', + 'VALIDATION_ERROR', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'CONFLICT', + 'RATE_LIMITED', + 'COMPANY_CONTEXT_MISSING', + ]) { + const entry = getErrorEntry(code) + expect(entry, `missing entry for ${code}`).toBeDefined() + expect(entry?.message_sv).toBeTruthy() + expect(entry?.message_en).toBeTruthy() + } + }) + + it('listErrorCodes returns at least the bookkeeping + generic + provider codes', () => { + const codes = listErrorCodes() + expect(codes.length).toBeGreaterThan(20) + expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED') + expect(codes).toContain('PROVIDER_AUTH_EXPIRED') + }) +}) + +describe('errorResponse', () => { + it('maps BookkeepingError to its code + structured details + Swedish message', async () => { + const err = new JournalEntryNotBalancedError(100, 90) + const res = errorResponse(err, noopLogger, { requestId: 'req_1' }) + expect(res.status).toBe(400) + expect(res.headers.get('X-Request-Id')).toBe('req_1') + const body = await readEnvelope(res) + expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED') + expect(body.error.message).toMatch(/balanserar inte/i) + expect(body.error.requestId).toBe('req_1') + expect(body.error.details).toMatchObject({ totalDebit: 100, totalCredit: 90 }) + }) + + it('preserves AccountsNotInChartError details', async () => { + const err = new AccountsNotInChartError(['1930', '2641']) + const res = errorResponse(err, noopLogger, { requestId: 'req_2' }) + const body = await readEnvelope(res) + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.error.details).toMatchObject({ account_numbers: ['1930', '2641'] }) + }) + + it('maps ZodError to VALIDATION_ERROR with field issues', async () => { + let zodErr: ZodError + try { + z.object({ name: z.string().min(1) }).parse({ name: '' }) + throw new Error('should have thrown') + } catch (e) { + zodErr = e as ZodError + } + const res = errorResponse(zodErr, noopLogger, { requestId: 'req_3' }) + expect(res.status).toBe(400) + const body = await readEnvelope(res) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details).toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({ field: 'name' }), + ]), + }) + }) + + it('maps Postgres unique violation to VALIDATION_ERROR with pgCode', async () => { + const pgErr = Object.assign(new Error('duplicate key'), { code: '23505' }) + const res = errorResponse(pgErr, noopLogger, { requestId: 'req_4' }) + expect(res.status).toBe(400) + const body = await readEnvelope(res) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details).toMatchObject({ pgCode: '23505' }) + }) + + it('falls back to INTERNAL_ERROR for unknown shapes', async () => { + const res = errorResponse(new Error('boom'), noopLogger, { requestId: 'req_5' }) + expect(res.status).toBe(500) + const body = await readEnvelope(res) + expect(body.error.code).toBe('INTERNAL_ERROR') + expect(body.error.requestId).toBe('req_5') + }) + + it('passes through entries with remediation hints', async () => { + const res = errorResponseFromCode('PROVIDER_AUTH_EXPIRED', noopLogger, { requestId: 'req_6' }) + const body = await readEnvelope(res) + expect(body.error.code).toBe('PROVIDER_AUTH_EXPIRED') + expect(res.status).toBe(401) + }) + + it('errorResponseFromCode emits requestId in header', () => { + const res = errorResponseFromCode('NOT_FOUND', noopLogger, { requestId: 'req_7' }) + expect(res.headers.get('X-Request-Id')).toBe('req_7') + }) + + it('preserves EntryDateOutsideFiscalPeriodError fields', async () => { + const err = new EntryDateOutsideFiscalPeriodError( + '2026-01-01', + 'FY2025', + '2025-01-01', + '2025-12-31', + ) + const body = await readEnvelope(errorResponse(err, noopLogger, { requestId: 'req_8' })) + expect(body.error.code).toBe('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD') + expect(body.error.details).toMatchObject({ + entryDate: '2026-01-01', + periodName: 'FY2025', + periodStart: '2025-01-01', + periodEnd: '2025-12-31', + }) + }) +}) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 8a89f373..2b0170cb 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -231,6 +231,14 @@ export function getErrorMessage( if (typeof error === 'object' && error !== null) { const obj = error as Record + // Bare envelope inner-error shape: { code, message, ... }. Happens when a + // caller forwards `result.error` (the inner object) instead of the whole + // `result`. Treat it the same as the wrapped form so we always end up with + // the registry's Swedish message in the toast — never `[object Object]`. + if (typeof obj.code === 'string' && typeof obj.message === 'string' && obj.message.trim()) { + return obj.message + } + // Structured application error: { error: { code, message, ... } } if (typeof obj.error === 'object' && obj.error !== null) { const structured = obj.error as { diff --git a/lib/errors/get-structured-error.ts b/lib/errors/get-structured-error.ts index 100e8a31..ea7b3c44 100644 --- a/lib/errors/get-structured-error.ts +++ b/lib/errors/get-structured-error.ts @@ -11,17 +11,33 @@ * that fixes the problem. Optional — only set when there's a clear * mechanical next step * - * Used by the MCP server's tool error wrapper. UI callers continue to use the - * string-only getErrorMessage() — this is additive. + * Both MCP and REST consume this. errorResponse() below produces the standard + * REST envelope so a single registry covers every entry point. */ +import { NextResponse } from 'next/server' +import { ZodError } from 'zod' import { getErrorMessage } from './get-error-message' +import { + getErrorEntry, + type StructuredErrorEntry, + type StructuredErrorRemediation, +} from './structured-errors' +import { + AccountsNotInChartError, + BookkeepingDatabaseError, + CannotCorrectNonPostedError, + CannotReverseNonPostedError, + EntryAlreadyReversedError, + EntryDateOutsideFiscalPeriodError, + FiscalPeriodNotFoundError, + InvalidMappingResultError, + JournalEntryNotBalancedError, + JournalEntryNotFoundError, + CurrencyRevaluationAlreadyExistsError, + isBookkeepingError, +} from '../bookkeeping/errors' -export interface StructuredErrorRemediation { - description: string - tool?: string - args?: Record - resource?: string -} +export type { StructuredErrorRemediation } export interface StructuredError { code: string @@ -41,58 +57,6 @@ interface StructuredErrorOptions { toolName?: string } -const ERROR_CODE_REMEDIATION: Record = { - ACCOUNTS_NOT_IN_CHART: { - description: 'One or more BAS accounts referenced are not active in the chart of accounts. Activate them via the bookkeeping settings, or use a different category.', - resource: 'gnubok://chart-of-accounts', - }, - JOURNAL_ENTRY_NOT_BALANCED: { - description: 'Debits and credits do not match. Recalculate the lines so totals are equal before retrying.', - }, - FISCAL_PERIOD_NOT_FOUND: { - description: 'No fiscal period covers the entry date. Create or extend the relevant period before retrying.', - resource: 'gnubok://period/active', - }, - ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: { - description: 'The entry date is outside the active fiscal period. Use a date inside an open period or create one that covers it.', - resource: 'gnubok://period/active', - }, - CANNOT_REVERSE_NON_POSTED: { - description: 'Only posted entries can be reversed. Commit the draft first or pick a posted entry.', - }, - CANNOT_CORRECT_NON_POSTED: { - description: 'Only posted entries can be corrected. Commit the draft first or pick a posted entry.', - }, - ENTRY_ALREADY_REVERSED: { - description: 'Another caller reversed this entry concurrently. Re-fetch the entry list and pick a different one.', - }, - PERIOD_NOT_LOCKED: { - description: 'The period must be locked before it can be closed. Call gnubok_lock_period first.', - tool: 'gnubok_lock_period', - }, - PERIOD_HAS_UNBOOKED_TRANSACTIONS: { - description: 'The period contains uncategorized business transactions. Categorize or mark them private before locking.', - tool: 'gnubok_list_uncategorized_transactions', - }, - YEAR_END_NOT_RUN: { - description: 'Year-end closing must be executed before the period can be closed. Run the year-end procedure first.', - }, - INSUFFICIENT_SCOPE: { - description: 'The current API key does not have the required scope. Mint a new key with the missing scope or grant it through the API key settings.', - resource: 'gnubok://capabilities', - }, - TRANSACTION_ALREADY_CATEGORIZED: { - description: 'The transaction already has a journal entry. Use gnubok_uncategorize_transaction first if you need to recategorize.', - tool: 'gnubok_uncategorize_transaction', - }, - INVOICE_ALREADY_SENT: { - description: 'The invoice is already sent or paid; sending again would create a duplicate.', - }, - IDEMPOTENCY_KEY_REUSE: { - description: 'This idempotency_key was previously used with a different request body. Use a fresh UUID for a new operation, or send the original request body to replay.', - }, -} - /** * Pull a stable code out of various error shapes. */ @@ -164,7 +128,8 @@ export function getStructuredError( const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR' - let remediation = ERROR_CODE_REMEDIATION[code] + const entry = getErrorEntry(code) + let remediation = entry?.remediation // Specialize INSUFFICIENT_SCOPE with the actual scope name when known. if (code === 'INSUFFICIENT_SCOPE' && options.attemptedScope && remediation) { @@ -181,3 +146,240 @@ export function getStructuredError( ...(remediation ? { remediation } : {}), } } + +// ──────────────────────────────────────────────────────────────────── +// REST error envelope +// ──────────────────────────────────────────────────────────────────── + +export interface ErrorEnvelope { + error: { + code: string + message: string + message_en?: string + remediation?: StructuredErrorRemediation + requestId?: string + details?: unknown + } +} + +interface ErrorResponseContext { + requestId?: string + /** Additional details to attach to the response for the user/agent. */ + details?: unknown + /** When known, override the http status from the registry entry. */ + status?: number +} + +interface MinimalLogger { + error: (msg: string, ...args: unknown[]) => void +} + +function entryFor(code: string): StructuredErrorEntry { + return ( + getErrorEntry(code) ?? + getErrorEntry('INTERNAL_ERROR') ?? { + httpStatus: 500, + message_sv: 'Något gick fel. Försök igen.', + message_en: 'Internal server error.', + } + ) +} + +function postgresCodeToStructured(code: string): string | null { + switch (code) { + case '23505': + case '23503': + case '23514': + case '22P02': + case '22003': + return 'VALIDATION_ERROR' + case '23502': + return 'VALIDATION_ERROR' + case '42501': + return 'FORBIDDEN' + case '42P01': + return 'NOT_FOUND' + case '40001': + case '40P01': + return 'CONFLICT' + default: + return null + } +} + +function isZodError(err: unknown): err is ZodError { + return err instanceof ZodError || (err instanceof Error && err.name === 'ZodError') +} + +function isPostgresError(err: unknown): err is { code: string; message: string } { + return ( + typeof err === 'object' && + err !== null && + typeof (err as { code?: unknown }).code === 'string' && + /^[0-9A-Z]{5}$/.test((err as { code: string }).code) + ) +} + +/** + * Build the canonical REST error envelope for any thrown value. + * + * Order of dispatch: + * 1. typed BookkeepingError → reuses bookkeepingErrorResponse() + * 2. ZodError → VALIDATION_ERROR with field-level details + * 3. Postgres error code → mapped to a structured code + * 4. Error with `code` field present in registry → use that + * 5. Anything else → INTERNAL_ERROR + * + * Always logs the underlying error (no silent error returns). The caller + * must pass a logger so the request id propagates to the log line. + */ +export function errorResponse( + err: unknown, + log: MinimalLogger, + ctx: ErrorResponseContext = {}, +): NextResponse { + // 1. Bookkeeping domain errors — route through the registry, preserving + // the structured details each typed error class carries. + if (isBookkeepingError(err)) { + const { code, details } = extractBookkeepingDetails(err) + log.error(code, err as Error, { requestId: ctx.requestId }) + const entry = entryFor(code) + return buildResponse(code, entry, ctx.requestId, details ?? ctx.details) + } + + // 2. Zod validation errors + if (isZodError(err)) { + const issues = (err as ZodError).issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + code: i.code, + })) + log.error('validation failed', err as Error, { + requestId: ctx.requestId, + issueCount: issues.length, + }) + const entry = entryFor('VALIDATION_ERROR') + const details = mergeDetails({ issues }, ctx.details) + return buildResponse('VALIDATION_ERROR', entry, ctx.requestId, details) + } + + // 3. Postgres errors + if (isPostgresError(err)) { + const mapped = postgresCodeToStructured(err.code) + log.error('database error', err as unknown as Error, { + requestId: ctx.requestId, + pgCode: err.code, + }) + if (mapped) { + const entry = entryFor(mapped) + const details = mergeDetails({ pgCode: err.code }, ctx.details) + return buildResponse(mapped, entry, ctx.requestId, details) + } + } + + // 4. Errors with a known structured code on them + const code = extractCode(err) + if (code && getErrorEntry(code)) { + const entry = entryFor(code) + log.error(`${code}`, err instanceof Error ? err : new Error(String(err)), { requestId: ctx.requestId }) + const status = ctx.status ?? entry.httpStatus + return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details) + } + + // 5. Fallback — log the actual error so we can still debug + log.error('unhandled error', err instanceof Error ? err : new Error(String(err)), { + requestId: ctx.requestId, + }) + const fallback = entryFor('INTERNAL_ERROR') + return buildResponse('INTERNAL_ERROR', fallback, ctx.requestId, ctx.details) +} + +function mergeDetails( + base: Record, + extra: unknown, +): Record { + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + return { ...base, ...(extra as Record) } + } + return base +} + +function extractBookkeepingDetails(err: unknown): { code: string; details?: unknown } { + if (err instanceof AccountsNotInChartError) { + return { code: err.code, details: { account_numbers: err.accountNumbers } } + } + if (err instanceof JournalEntryNotBalancedError) { + return { + code: err.code, + details: { totalDebit: err.totalDebit, totalCredit: err.totalCredit, kind: err.kind }, + } + } + if (err instanceof FiscalPeriodNotFoundError) return { code: err.code } + if (err instanceof EntryDateOutsideFiscalPeriodError) { + return { + code: err.code, + details: { + entryDate: err.entryDate, + periodName: err.periodName, + periodStart: err.periodStart, + periodEnd: err.periodEnd, + }, + } + } + if (err instanceof JournalEntryNotFoundError) return { code: err.code } + if (err instanceof CannotReverseNonPostedError) { + return { code: err.code, details: { currentStatus: err.currentStatus } } + } + if (err instanceof CannotCorrectNonPostedError) { + return { code: err.code, details: { currentStatus: err.currentStatus } } + } + if (err instanceof EntryAlreadyReversedError) return { code: err.code } + if (err instanceof CurrencyRevaluationAlreadyExistsError) return { code: err.code } + if (err instanceof InvalidMappingResultError) { + return { + code: err.code, + details: { debitAccount: err.debitAccount, creditAccount: err.creditAccount }, + } + } + if (err instanceof BookkeepingDatabaseError) { + return { code: err.code, details: { operation: err.operation } } + } + return { code: 'INTERNAL_ERROR' } +} + +function buildResponse( + code: string, + entry: StructuredErrorEntry, + requestId: string | undefined, + details: unknown, +): NextResponse { + const body: ErrorEnvelope = { + error: { + code, + message: entry.message_sv, + message_en: entry.message_en, + ...(entry.remediation ? { remediation: entry.remediation } : {}), + ...(requestId ? { requestId } : {}), + ...(details !== undefined ? { details } : {}), + }, + } + const res = NextResponse.json(body, { status: entry.httpStatus }) + if (requestId) res.headers.set('X-Request-Id', requestId) + return res +} + +/** + * Construct an envelope-shaped error directly from a code (when the route + * already knows the failure mode). Skips dispatch — useful inside a handler + * that wants the standard shape without throwing. + */ +export function errorResponseFromCode( + code: string, + log: MinimalLogger, + ctx: ErrorResponseContext & { reason?: string } = {}, +): NextResponse { + const entry = entryFor(code) + log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId }) + const status = ctx.status ?? entry.httpStatus + return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details) +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts new file mode 100644 index 00000000..41590933 --- /dev/null +++ b/lib/errors/structured-errors.ts @@ -0,0 +1,1232 @@ +/** + * Canonical registry of structured error codes used by both REST routes and + * the MCP server. + * + * Each entry defines: + * - httpStatus: status returned by errorResponse() for this code + * - message_sv: Swedish user-facing message (consumed by toast) + * - message_en: English message for agents and developer logs + * - remediation: optional pointer to a fix (tool/resource/description) + * + * Adding a new code = add a row here. The error-code-matrix in + * `.claude/plans/for-all-of-those-mutable-sunset.md` lists the codes per + * operation; keep that document and this file in sync. + * + * Codes follow `__` naming. Stable forever once + * shipped — agents pattern-match on them. + */ + +export interface StructuredErrorRemediation { + description: string + tool?: string + args?: Record + resource?: string +} + +export interface StructuredErrorEntry { + httpStatus: number + message_sv: string + message_en: string + remediation?: StructuredErrorRemediation +} + +// ───────────────────────────────────────────────────────────────── +// Generic / cross-cutting codes +// ───────────────────────────────────────────────────────────────── + +const GENERIC: Record = { + UNKNOWN_ERROR: { + httpStatus: 500, + message_sv: 'Något gick fel. Försök igen.', + message_en: 'An unexpected error occurred.', + }, + INTERNAL_ERROR: { + httpStatus: 500, + message_sv: 'Ett oväntat serverfel uppstod. Försök igen senare.', + message_en: 'Internal server error.', + }, + VALIDATION_ERROR: { + httpStatus: 400, + message_sv: 'Förfrågan innehåller ogiltiga uppgifter.', + message_en: 'Validation error.', + }, + UNAUTHORIZED: { + httpStatus: 401, + message_sv: 'Din session har gått ut. Logga in igen.', + message_en: 'Authentication required.', + }, + MFA_REQUIRED: { + httpStatus: 403, + message_sv: 'Tvåstegsverifiering krävs för att utföra åtgärden.', + message_en: 'MFA verification required.', + }, + FORBIDDEN: { + httpStatus: 403, + message_sv: 'Du har inte behörighet att utföra denna åtgärd.', + message_en: 'Insufficient permissions.', + }, + NOT_FOUND: { + httpStatus: 404, + message_sv: 'Resursen kunde inte hittas.', + message_en: 'Resource not found.', + }, + CONFLICT: { + httpStatus: 409, + message_sv: 'En konflikt uppstod. Ladda om sidan och försök igen.', + message_en: 'Conflict.', + }, + RATE_LIMITED: { + httpStatus: 429, + message_sv: 'För många förfrågningar. Vänta en stund och försök igen.', + message_en: 'Rate limit exceeded.', + }, + COMPANY_CONTEXT_MISSING: { + httpStatus: 400, + message_sv: 'Ingen aktiv företagskontext. Välj ett företag och försök igen.', + message_en: 'No active company context resolved for the request.', + }, + IDEMPOTENCY_KEY_REUSE: { + httpStatus: 409, + message_sv: 'Idempotensnyckeln har redan använts med en annan begäran.', + message_en: 'Idempotency key was previously used with a different request body.', + remediation: { + description: + 'Use a fresh UUID for a new operation, or send the original request body to replay.', + }, + }, + INSUFFICIENT_SCOPE: { + httpStatus: 403, + message_sv: 'API-nyckeln saknar behörighet för denna åtgärd.', + message_en: 'The current API key does not have the required scope.', + remediation: { + description: + 'Mint a new key with the missing scope or grant it through the API key settings.', + resource: 'gnubok://capabilities', + }, + }, +} + +// ───────────────────────────────────────────────────────────────── +// Bookkeeping engine codes (already used by lib/bookkeeping/errors.ts) +// ───────────────────────────────────────────────────────────────── + +const BOOKKEEPING: Record = { + ACCOUNTS_NOT_IN_CHART: { + httpStatus: 400, + message_sv: 'Konton saknas i kontoplanen.', + message_en: 'One or more BAS accounts are not active in the chart of accounts.', + remediation: { + description: + 'Activate the missing accounts via bookkeeping settings, or use a different category.', + resource: 'gnubok://chart-of-accounts', + }, + }, + JOURNAL_ENTRY_NOT_BALANCED: { + httpStatus: 400, + message_sv: 'Verifikationen balanserar inte.', + message_en: 'Debits and credits do not match.', + remediation: { + description: 'Recalculate the lines so totals are equal before retrying.', + }, + }, + FISCAL_PERIOD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Räkenskapsperioden kunde inte hittas.', + message_en: 'No fiscal period covers the entry date.', + remediation: { + description: 'Create or extend the relevant fiscal period before retrying.', + resource: 'gnubok://period/active', + }, + }, + ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: { + httpStatus: 400, + message_sv: 'Datumet ligger utanför det valda räkenskapsåret.', + message_en: 'Entry date is outside the active fiscal period.', + remediation: { + description: 'Use a date inside an open period or create one that covers it.', + resource: 'gnubok://period/active', + }, + }, + JOURNAL_ENTRY_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Verifikationen kunde inte hittas.', + message_en: 'Journal entry not found.', + }, + CANNOT_REVERSE_NON_POSTED: { + httpStatus: 400, + message_sv: 'Endast bokförda verifikationer kan stornas.', + message_en: 'Only posted entries can be reversed.', + }, + CANNOT_CORRECT_NON_POSTED: { + httpStatus: 400, + message_sv: 'Endast bokförda verifikationer kan rättas.', + message_en: 'Only posted entries can be corrected.', + }, + ENTRY_ALREADY_REVERSED: { + httpStatus: 409, + message_sv: + 'Verifikationen har redan stornats av en annan användare. Ladda om sidan och försök igen.', + message_en: 'Entry was already reversed by a concurrent operation.', + }, + CURRENCY_REVALUATION_ALREADY_EXISTS: { + httpStatus: 409, + message_sv: 'En valutaomvärdering finns redan för denna period.', + message_en: 'Currency revaluation already exists for this period.', + }, + INVALID_MAPPING_RESULT: { + httpStatus: 400, + message_sv: 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.', + message_en: 'Mapping rules produced an invalid debit/credit account pair.', + }, + BOOKKEEPING_DATABASE_ERROR: { + httpStatus: 500, + message_sv: 'Verifikationen kunde inte sparas. Försök igen.', + message_en: 'Bookkeeping database operation failed.', + }, + PERIOD_LOCKED: { + httpStatus: 400, + message_sv: 'Bokföringen är låst för denna period.', + message_en: 'Period is locked or closed; entries cannot be added.', + }, + PERIOD_NOT_LOCKED: { + httpStatus: 400, + message_sv: 'Perioden måste först låsas innan den kan stängas.', + message_en: 'Period must be locked before it can be closed.', + remediation: { + description: 'Call gnubok_lock_period before closing.', + tool: 'gnubok_lock_period', + }, + }, + PERIOD_HAS_UNBOOKED_TRANSACTIONS: { + httpStatus: 400, + message_sv: + 'Perioden innehåller okategoriserade affärstransaktioner. Bokför eller markera dem som privata innan låsning.', + message_en: 'The period contains uncategorized business transactions.', + remediation: { + description: 'Categorize or mark uncategorized transactions before locking.', + tool: 'gnubok_list_uncategorized_transactions', + }, + }, + YEAR_END_NOT_RUN: { + httpStatus: 400, + message_sv: 'Bokslutsåtgärder måste utföras innan perioden kan stängas.', + message_en: 'Year-end closing must be executed before the period can be closed.', + }, + TRANSACTION_ALREADY_CATEGORIZED: { + httpStatus: 409, + message_sv: + 'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.', + message_en: 'The transaction already has a journal entry.', + remediation: { + description: + 'Use gnubok_uncategorize_transaction first if you need to recategorize.', + tool: 'gnubok_uncategorize_transaction', + }, + }, + INVOICE_ALREADY_SENT: { + httpStatus: 409, + message_sv: 'Fakturan har redan skickats eller betalats.', + message_en: 'The invoice is already sent or paid.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Wave 1: invoicing & transactions +// ───────────────────────────────────────────────────────────────── + +const TRANSACTIONS: Record = { + TX_CATEGORIZE_TX_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Transaktionen kunde inte hittas.', + message_en: 'Transaction not found.', + }, + TX_CATEGORIZE_INVALID_ACCOUNT: { + httpStatus: 400, + message_sv: 'Det valda kontot finns inte i kontoplanen.', + message_en: 'The supplied account does not exist in the chart of accounts.', + remediation: { + description: 'Activate the account in the chart of accounts or pick a different one.', + resource: 'gnubok://chart-of-accounts', + }, + }, + TX_CATEGORIZE_INVALID_TEMPLATE: { + httpStatus: 400, + message_sv: 'Bokföringsmallen är ogiltig eller passar inte din bolagsform.', + message_en: 'The supplied booking template is invalid or does not match the entity type.', + }, + TX_CATEGORIZE_INVALID_MAPPING: { + httpStatus: 400, + message_sv: 'Konteringen saknar debet- eller kreditkonto.', + message_en: 'Mapping result is missing a debit or credit account.', + }, + TX_CATEGORIZE_RACE: { + httpStatus: 409, + message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.', + message_en: 'Transaction was already categorized by another request.', + }, + TX_UNCATEGORIZE_NO_LINKED_ENTRY: { + httpStatus: 400, + message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.', + message_en: 'Transaction has no linked journal entry to reverse.', + }, +} + +const MATCH_INVOICE: Record = { + MATCH_INVOICE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Fakturan kunde inte hittas.', + message_en: 'Invoice not found.', + }, + MATCH_INVOICE_NOT_INCOME: { + httpStatus: 400, + message_sv: 'Endast intäktstransaktioner kan matchas mot kundfakturor.', + message_en: 'Only income transactions can be matched to customer invoices.', + }, + MATCH_INVOICE_TX_ALREADY_LINKED: { + httpStatus: 400, + message_sv: 'Transaktionen är redan kopplad till en faktura.', + message_en: 'Transaction is already linked to an invoice.', + }, + MATCH_INVOICE_NOT_OPEN: { + httpStatus: 400, + message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.', + message_en: 'Invoice is not in an unpaid state.', + }, + MATCH_INVOICE_ALREADY_PAID: { + httpStatus: 409, + message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.', + message_en: 'Invoice has already been fully paid or is no longer matchable.', + }, + MATCH_INVOICE_DUPLICATE_PAYMENT: { + httpStatus: 409, + message_sv: 'Den här transaktionen är redan matchad mot fakturan.', + message_en: 'This transaction is already matched to this invoice.', + }, + MATCH_INVOICE_RECORD_PAYMENT_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte registrera fakturabetalningen.', + message_en: 'Failed to record invoice payment.', + }, + MATCH_INVOICE_LINK_TX_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte koppla transaktionen till fakturan.', + message_en: 'Failed to link transaction to invoice.', + }, + MATCH_INVOICE_PARTIAL: { + httpStatus: 200, + message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.', + message_en: 'Match recorded but the journal entry could not be created.', + }, +} + +const MATCH_SI: Record = { + MATCH_SI_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Leverantörsfakturan kunde inte hittas.', + message_en: 'Supplier invoice not found.', + }, + MATCH_SI_NOT_EXPENSE: { + httpStatus: 400, + message_sv: 'Endast utgiftstransaktioner kan matchas mot leverantörsfakturor.', + message_en: 'Only expense transactions can be matched to supplier invoices.', + }, + MATCH_SI_TX_ALREADY_LINKED: { + httpStatus: 400, + message_sv: 'Transaktionen är redan kopplad till en leverantörsfaktura.', + message_en: 'Transaction is already linked to a supplier invoice.', + }, + MATCH_SI_ALREADY_PAID: { + httpStatus: 400, + message_sv: 'Leverantörsfakturan är redan betald eller krediterad.', + message_en: 'Supplier invoice is already paid or credited.', + }, + MATCH_SI_NOT_OPEN: { + httpStatus: 409, + message_sv: 'Leverantörsfakturan har redan slutbetalats av en annan förfrågan.', + message_en: 'Supplier invoice has already been fully paid or is no longer matchable.', + }, + MATCH_SI_DUPLICATE_PAYMENT: { + httpStatus: 409, + message_sv: 'Den här transaktionen är redan matchad mot leverantörsfakturan.', + message_en: 'This transaction is already matched to this supplier invoice.', + }, + MATCH_SI_RECORD_PAYMENT_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte registrera leverantörsfakturabetalningen.', + message_en: 'Failed to record supplier invoice payment.', + }, + MATCH_SI_LINK_TX_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte koppla transaktionen till leverantörsfakturan.', + message_en: 'Failed to link transaction to supplier invoice.', + }, +} + +const INVOICE: Record = { + INVOICE_CUSTOMER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Kunden kunde inte hittas.', + message_en: 'Customer not found.', + }, + INVOICE_CREATE_VAT_RULE_VIOLATION: { + httpStatus: 400, + message_sv: 'Momssatsen är inte tillåten för denna kundtyp.', + message_en: 'The VAT rate is not allowed for this customer type.', + }, + INVOICE_CREATE_INSERT_FAILED: { + httpStatus: 500, + message_sv: 'Fakturan kunde inte sparas.', + message_en: 'Invoice insert failed.', + }, + INVOICE_CREATE_ITEMS_FAILED: { + httpStatus: 500, + message_sv: 'Fakturaraderna kunde inte sparas.', + message_en: 'Invoice items insert failed.', + }, + INVOICE_CREDIT_ORIGINAL_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Ursprungsfakturan kunde inte hittas.', + message_en: 'Original invoice not found.', + }, + INVOICE_CREDIT_NOT_INVOICE: { + httpStatus: 400, + message_sv: 'Kreditfakturor kan endast skapas från riktiga fakturor.', + message_en: 'Credit notes can only be created from standard invoices.', + }, + INVOICE_CREDIT_ALREADY_CREDITED: { + httpStatus: 400, + message_sv: 'Fakturan har redan krediterats.', + message_en: 'Invoice has already been credited.', + }, + INVOICE_CREDIT_NOT_SENT: { + httpStatus: 400, + message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.', + message_en: 'Only sent, paid, or overdue invoices can be credited.', + }, + INVOICE_SEND_EMAIL_NOT_CONFIGURED: { + httpStatus: 503, + message_sv: + 'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta.', + message_en: 'Email service is not configured.', + remediation: { + description: 'Set RESEND_API_KEY and RESEND_FROM_EMAIL in the deployment environment.', + }, + }, + INVOICE_SEND_NO_CUSTOMER_EMAIL: { + httpStatus: 400, + message_sv: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.', + message_en: 'Customer has no email address.', + remediation: { description: 'Add an email address on the customer record before sending.' }, + }, + INVOICE_SEND_COMPANY_SETTINGS_MISSING: { + httpStatus: 404, + message_sv: 'Företagsinställningar saknas.', + message_en: 'Company settings are missing.', + }, + INVOICE_SEND_NUMBER_ASSIGN_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tilldela fakturanummer.', + message_en: 'Failed to assign invoice number on send.', + }, + INVOICE_SEND_PROVIDER_FAILED: { + httpStatus: 502, + message_sv: 'E-postleverantören kunde inte skicka meddelandet.', + message_en: 'The email provider could not deliver the message.', + }, + INVOICE_SEND_PARTIAL: { + httpStatus: 200, + message_sv: + 'Fakturan skickades men en efterföljande åtgärd misslyckades (verifikation eller PDF-bilaga).', + message_en: 'Invoice was sent but a follow-up step (journal entry or PDF) failed.', + }, + INVOICE_PAID_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Fakturan kunde inte hittas.', + message_en: 'Invoice not found.', + }, + INVOICE_PAID_NOT_PAYABLE: { + httpStatus: 400, + message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.', + message_en: 'Invoice is not in a payable status.', + }, + INVOICE_PAID_LINES_UNBALANCED: { + httpStatus: 400, + message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).', + message_en: 'Custom journal lines do not balance.', + }, + INVOICE_PAID_NO_FISCAL_PERIOD: { + httpStatus: 400, + message_sv: 'Ingen öppen räkenskapsperiod för betalningsdatumet.', + message_en: 'No open fiscal period covers the payment date.', + }, + INVOICE_PAID_RACE: { + httpStatus: 409, + message_sv: 'Fakturan har redan betalats av en annan förfrågan.', + message_en: 'Invoice was already paid by another request.', + }, + INVOICE_PAID_BOOK_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte bokföra betalningen.', + message_en: 'Failed to create payment journal entry.', + }, +} + +const SUPPLIER_INVOICE: Record = { + SI_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Leverantörsfakturan kunde inte hittas.', + message_en: 'Supplier invoice not found.', + }, + SI_APPROVE_NOT_REGISTERED: { + httpStatus: 400, + message_sv: 'Endast registrerade fakturor kan godkännas.', + message_en: 'Only invoices in registered status can be approved.', + }, + SI_APPROVE_UPDATE_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte godkänna leverantörsfakturan.', + message_en: 'Failed to update supplier invoice status to approved.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Wave 2: periods, year-end, reports +// ───────────────────────────────────────────────────────────────── + +const PERIOD: Record = { + PERIOD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Räkenskapsperioden kunde inte hittas.', + message_en: 'Fiscal period not found.', + }, + PERIOD_LOCK_FAILED: { + httpStatus: 400, + message_sv: 'Perioden kunde inte låsas.', + message_en: 'Failed to lock period.', + }, + PERIOD_LOCK_HAS_DRAFTS: { + httpStatus: 400, + message_sv: 'Perioden innehåller verifikationsutkast som måste bokföras eller raderas innan låsning.', + message_en: 'Period contains draft journal entries.', + }, + PERIOD_LOCK_ALREADY_LOCKED: { + httpStatus: 409, + message_sv: 'Perioden är redan låst.', + message_en: 'Period is already locked.', + }, +} + +const YEAR_END: Record = { + YEAR_END_PREVIEW_FAILED: { + httpStatus: 400, + message_sv: 'Bokslutsförhandsgranskningen misslyckades.', + message_en: 'Failed to preview year-end closing.', + }, + YEAR_END_FAILED: { + httpStatus: 400, + message_sv: 'Bokslutet kunde inte verkställas.', + message_en: 'Failed to execute year-end closing.', + }, + YEAR_END_PRIOR_PERIOD_OPEN: { + httpStatus: 400, + message_sv: 'En tidigare period är fortfarande öppen. Stäng den först.', + message_en: 'A prior fiscal period is still open.', + }, + YEAR_END_UNBALANCED_TRIAL: { + httpStatus: 400, + message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.', + message_en: 'Trial balance does not balance.', + }, +} + +const OPENING_BAL: Record = { + OPENING_BAL_PERIOD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Räkenskapsperioden kunde inte hittas.', + message_en: 'Fiscal period not found.', + }, +} + +const FX: Record = { + FX_PERIOD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Räkenskapsperioden kunde inte hittas.', + message_en: 'Fiscal period not found.', + }, + FX_PERIOD_CLOSED: { + httpStatus: 400, + message_sv: 'Perioden är redan stängd. Valutaomvärdering kan inte köras.', + message_en: 'Period is already closed; currency revaluation cannot be run.', + }, + FX_FAILED: { + httpStatus: 400, + message_sv: 'Valutaomvärderingen misslyckades.', + message_en: 'Currency revaluation failed.', + }, +} + +const REPORT: Record = { + REPORT_PERIOD_REQUIRED: { + httpStatus: 400, + message_sv: 'period_id krävs.', + message_en: 'period_id query parameter is required.', + }, + REPORT_GENERATION_FAILED: { + httpStatus: 500, + message_sv: 'Rapporten kunde inte genereras.', + message_en: 'Failed to generate the report.', + }, +} + +const VAT_REPORT: Record = { + VAT_REPORT_MISSING_PARAMS: { + httpStatus: 400, + message_sv: 'periodType, year och period krävs.', + message_en: 'periodType, year and period query parameters are required.', + }, + VAT_REPORT_INVALID_PERIOD_TYPE: { + httpStatus: 400, + message_sv: 'periodType måste vara monthly, quarterly eller yearly.', + message_en: 'periodType must be one of monthly, quarterly, yearly.', + }, + VAT_REPORT_INVALID_YEAR: { + httpStatus: 400, + message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.', + message_en: 'year must be a number between 2000 and 2100.', + }, + VAT_REPORT_INVALID_PERIOD: { + httpStatus: 400, + message_sv: 'period är ogiltig för vald periodtyp.', + message_en: 'period is invalid for the chosen period type.', + }, + VAT_REPORT_GENERATION_FAILED: { + httpStatus: 500, + message_sv: 'Momsdeklarationen kunde inte beräknas.', + message_en: 'Failed to calculate VAT declaration.', + }, +} + +const SIE_EXPORT: Record = { + SIE_EXPORT_COMPANY_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Företagsinställningar saknas — SIE-exporten kan inte skapas.', + message_en: 'Company settings missing; SIE export cannot be generated.', + }, + SIE_EXPORT_FAILED: { + httpStatus: 500, + message_sv: 'SIE-exporten misslyckades.', + message_en: 'Failed to generate SIE export.', + }, +} + +const TAX_DECL: Record = { + TAX_DECL_GENERATION_FAILED: { + httpStatus: 500, + message_sv: 'Skattedeklarationen kunde inte genereras.', + message_en: 'Failed to generate tax declaration.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Wave 3: imports (SIE, bank-file, opening-balance) +// ───────────────────────────────────────────────────────────────── + +const SIE_IMPORT: Record = { + SIE_PARSE_NO_FILE: { + httpStatus: 400, + message_sv: 'Ingen fil bifogad i förfrågan.', + message_en: 'No file attached to the request.', + }, + SIE_PARSE_INVALID_TYPE: { + httpStatus: 400, + message_sv: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.', + message_en: 'Unsupported file type; upload a .sie or .se file.', + }, + SIE_PARSE_FILE_TOO_LARGE: { + httpStatus: 400, + message_sv: 'Filen är för stor. Maxstorlek är 50 MB.', + message_en: 'File exceeds the 50 MB size limit.', + }, + SIE_PARSE_EMPTY: { + httpStatus: 400, + message_sv: 'Filen är tom (0 bytes). Kontrollera exporten från bokföringsprogrammet.', + message_en: 'File is empty.', + }, + SIE_PARSE_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.', + message_en: 'Failed to parse the SIE file.', + }, + SIE_PARSE_VALIDATION_FAILED: { + httpStatus: 400, + message_sv: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.', + message_en: 'SIE file failed validation.', + }, + SIE_DUPLICATE_FILE: { + httpStatus: 409, + message_sv: 'Den här filen har redan importerats.', + message_en: 'File has already been imported.', + }, + SIE_DUPLICATE_PERIOD: { + httpStatus: 409, + message_sv: 'En SIE-import för ett överlappande räkenskapsår finns redan.', + message_en: 'An SIE import for an overlapping fiscal period already exists.', + }, + SIE_IMPORT_UNMAPPED_ACCOUNTS: { + httpStatus: 400, + message_sv: 'Vissa konton saknar mappning. Gå tillbaka till kontomappningssteget och koppla alla konton.', + message_en: 'One or more accounts have no mapping target.', + remediation: { description: 'Map every source account to a BAS account before importing.' }, + }, + SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte aktivera konton i kontoplanen. Kontrollera att kontona inte redan finns med andra inställningar.', + message_en: 'Failed to activate mapped accounts in the chart of accounts.', + }, + SIE_IMPORT_FAILED: { + httpStatus: 400, + message_sv: 'Importen slutfördes med fel. Se detaljerna nedan.', + message_en: 'SIE import completed with errors.', + }, + SIE_IMPORT_UNEXPECTED: { + httpStatus: 500, + message_sv: 'Importen avbröts oväntat. Ingen data har sparats.', + message_en: 'Unexpected error during SIE import; no data was committed.', + }, + SIE_REPLACE_FAILED: { + httpStatus: 400, + message_sv: 'SIE-importen kunde inte ersättas.', + message_en: 'Failed to replace SIE import.', + }, +} + +const BANK_FILE: Record = { + BANK_FILE_NO_FILE: { + httpStatus: 400, + message_sv: 'Ingen fil bifogad i förfrågan.', + message_en: 'No file attached to the request.', + }, + BANK_FILE_TOO_LARGE: { + httpStatus: 400, + message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', + message_en: 'File exceeds the 10 MB size limit.', + }, + BANK_FILE_DUPLICATE: { + httpStatus: 409, + message_sv: 'Den här filen har redan importerats.', + message_en: 'Bank file has already been imported.', + }, + BANK_FILE_PARSE_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tolka bankfilen.', + message_en: 'Failed to parse the bank file.', + }, + BANK_FILE_NO_TRANSACTIONS: { + httpStatus: 400, + message_sv: 'Bankfilen innehåller inga transaktioner att importera.', + message_en: 'No transactions to import.', + }, + BANK_FILE_IMPORT_RECORD_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte skapa importpost.', + message_en: 'Failed to create the bank file import record.', + }, + BANK_FILE_EXECUTE_FAILED: { + httpStatus: 500, + message_sv: 'Bankfilsimporten misslyckades.', + message_en: 'Bank file import failed.', + }, +} + +const OPENING_BALANCE_IMPORT: Record = { + OB_NO_FILE: { + httpStatus: 400, + message_sv: 'Ingen fil bifogad.', + message_en: 'No file attached.', + }, + OB_FILE_TOO_LARGE: { + httpStatus: 400, + message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', + message_en: 'File exceeds the 10 MB size limit.', + }, + OB_INVALID_FORMAT: { + httpStatus: 400, + message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.', + message_en: 'Unsupported file format.', + }, + OB_INVALID_COLUMN_OVERRIDES: { + httpStatus: 400, + message_sv: 'Ogiltig kolumnmappning.', + message_en: 'Invalid column overrides JSON.', + }, + OB_PARSE_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tolka filen.', + message_en: 'Failed to parse the opening balance file.', + }, + OB_PERIOD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Räkenskapsperioden hittades inte.', + message_en: 'Fiscal period not found.', + }, + OB_PERIOD_CLOSED: { + httpStatus: 400, + message_sv: 'Räkenskapsperioden är stängd.', + message_en: 'Fiscal period is closed.', + }, + OB_PERIOD_LOCKED: { + httpStatus: 400, + message_sv: 'Räkenskapsperioden är låst.', + message_en: 'Fiscal period is locked.', + }, + OB_PERIOD_ALREADY_HAS_BALANCES: { + httpStatus: 409, + message_sv: 'Räkenskapsperioden har redan ingående balanser.', + message_en: 'Fiscal period already has opening balances set.', + }, + OB_TOO_FEW_LINES: { + httpStatus: 400, + message_sv: 'Minst två rader med belopp krävs.', + message_en: 'At least two lines with amounts are required.', + }, + OB_PNL_ACCOUNT: { + httpStatus: 400, + message_sv: 'Resultatkonton (klass 3-8) kan inte användas i ingående balanser.', + message_en: 'Profit & loss accounts (class 3-8) are not allowed in opening balances.', + }, + OB_UNBALANCED: { + httpStatus: 400, + message_sv: 'Debet och kredit balanserar inte.', + message_en: 'Opening balance debits and credits do not match.', + }, + OB_ACCOUNT_ACTIVATION_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte aktivera konton i kontoplanen.', + message_en: 'Failed to activate accounts in the chart of accounts.', + }, + OB_EXECUTE_FAILED: { + httpStatus: 500, + message_sv: 'Importen misslyckades.', + message_en: 'Opening balance import failed.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Wave 3 tail: provider migration extension codes +// ───────────────────────────────────────────────────────────────── + +const PROVIDER_MIGRATION: Record = { + PROVIDER_INVALID: { + httpStatus: 400, + message_sv: 'Okänd leverantör.', + message_en: 'Unknown provider.', + }, + PROVIDER_CONSENT_NOT_READY: { + httpStatus: 400, + message_sv: 'Anslutningen är inte klar. Slutför inloggningen först.', + message_en: 'Provider consent is not ready; finish authentication first.', + }, + PROVIDER_CONSENT_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Anslutningen kunde inte hittas.', + message_en: 'Provider consent not found.', + }, + PROVIDER_CONNECT_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte starta anslutningen till leverantören.', + message_en: 'Failed to start provider connection flow.', + }, + PROVIDER_TOKEN_REQUIRED: { + httpStatus: 400, + message_sv: 'API-token krävs för den här leverantören.', + message_en: 'apiToken is required for this provider.', + }, + PROVIDER_COMPANY_ID_REQUIRED: { + httpStatus: 400, + message_sv: 'companyId krävs för den här leverantören.', + message_en: 'companyId is required for this provider.', + }, + PROVIDER_TOKEN_SUBMIT_FAILED: { + httpStatus: 500, + message_sv: 'Tokensubmissionen misslyckades.', + message_en: 'Failed to submit provider token.', + }, + PROVIDER_PREVIEW_FAILED: { + httpStatus: 500, + message_sv: 'Förhandsgranskningen från leverantören misslyckades.', + message_en: 'Provider preview failed.', + }, + PROVIDER_SIE_FETCH_FAILED: { + httpStatus: 502, + message_sv: 'Kunde inte hämta SIE-data från leverantören.', + message_en: 'Failed to fetch SIE data from the provider.', + }, + PROVIDER_SIE_NO_YEARS: { + httpStatus: 404, + message_sv: 'Inga räkenskapsår 2024–2026 hittades hos leverantören.', + message_en: 'No fiscal years available for 2024–2026.', + }, + PROVIDER_SIE_ONLY_FORTNOX: { + httpStatus: 400, + message_sv: 'SIE-export stöds för närvarande endast för Fortnox.', + message_en: 'SIE export is currently only supported for Fortnox.', + }, + PROVIDER_MIGRATE_FAILED: { + httpStatus: 500, + message_sv: 'Migrationen från leverantören misslyckades.', + message_en: 'Provider migration failed.', + }, + PROVIDER_DISCONNECT_FAILED: { + httpStatus: 500, + message_sv: 'Frånkoppling från leverantören misslyckades.', + message_en: 'Provider disconnect failed.', + }, + PROVIDER_ACCEPT_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte slutföra anslutningen.', + message_en: 'Failed to accept consent.', + }, + PROVIDER_STATUS_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte hämta status från leverantören.', + message_en: 'Failed to fetch provider status.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Wave 4: documents, masters, salary, company, API keys +// ───────────────────────────────────────────────────────────────── + +const DOCUMENT: Record = { + DOC_UPLOAD_NO_FILE: { + httpStatus: 400, + message_sv: 'Ingen fil bifogad.', + message_en: 'No file attached.', + }, + DOC_UPLOAD_TOO_LARGE: { + httpStatus: 400, + message_sv: 'Filen är för stor.', + message_en: 'Uploaded file exceeds the size limit.', + }, + DOC_UPLOAD_UNSUPPORTED_TYPE: { + httpStatus: 400, + message_sv: 'Filtypen stöds inte.', + message_en: 'Unsupported file type.', + }, + DOC_UPLOAD_STORAGE_FAILED: { + httpStatus: 500, + message_sv: 'Filen kunde inte sparas.', + message_en: 'Document storage failed.', + }, + DOC_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Dokumentet kunde inte hittas.', + message_en: 'Document not found.', + }, + DOC_LINK_ENTRY_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Verifikationen kunde inte hittas.', + message_en: 'Journal entry not found.', + }, + DOC_LINK_ALREADY_LINKED: { + httpStatus: 409, + message_sv: 'Dokumentet är redan kopplat till en verifikation.', + message_en: 'Document is already linked to a journal entry.', + }, + DOC_LINK_FAILED: { + httpStatus: 500, + message_sv: 'Kopplingen misslyckades.', + message_en: 'Failed to link document to journal entry.', + }, +} + +const CUSTOMER: Record = { + CUSTOMER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Kunden kunde inte hittas.', + message_en: 'Customer not found.', + }, + CUSTOMER_DUPLICATE_ORG_NUMBER: { + httpStatus: 409, + message_sv: 'En kund med samma organisationsnummer finns redan.', + message_en: 'A customer with that organisation number already exists.', + }, + CUSTOMER_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Kunden kunde inte skapas.', + message_en: 'Failed to create customer.', + }, + CUSTOMER_UPDATE_FAILED: { + httpStatus: 500, + message_sv: 'Kunden kunde inte uppdateras.', + message_en: 'Failed to update customer.', + }, + CUSTOMER_DELETE_FAILED: { + httpStatus: 500, + message_sv: 'Kunden kunde inte tas bort.', + message_en: 'Failed to delete customer.', + }, + CUSTOMER_HAS_INVOICES: { + httpStatus: 409, + message_sv: 'Kunden har fakturor och kan inte tas bort.', + message_en: 'Customer cannot be deleted while invoices reference it.', + }, +} + +const SUPPLIER: Record = { + SUPPLIER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Leverantören kunde inte hittas.', + message_en: 'Supplier not found.', + }, + SUPPLIER_DUPLICATE_ORG_NUMBER: { + httpStatus: 409, + message_sv: 'En leverantör med samma organisationsnummer finns redan.', + message_en: 'A supplier with that organisation number already exists.', + }, + SUPPLIER_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Leverantören kunde inte skapas.', + message_en: 'Failed to create supplier.', + }, + SUPPLIER_UPDATE_FAILED: { + httpStatus: 500, + message_sv: 'Leverantören kunde inte uppdateras.', + message_en: 'Failed to update supplier.', + }, + SUPPLIER_DELETE_FAILED: { + httpStatus: 500, + message_sv: 'Leverantören kunde inte tas bort.', + message_en: 'Failed to delete supplier.', + }, +} + +const SUPPLIER_INVOICE_WAVE4: Record = { + SI_CREATE_DUPLICATE_INVOICE_NUMBER: { + httpStatus: 409, + message_sv: 'En leverantörsfaktura med samma nummer finns redan.', + message_en: 'A supplier invoice with that number already exists.', + }, + SI_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Leverantörsfakturan kunde inte skapas.', + message_en: 'Failed to create supplier invoice.', + }, + SI_PAID_ALREADY: { + httpStatus: 409, + message_sv: 'Leverantörsfakturan är redan betald eller krediterad.', + message_en: 'Supplier invoice is already paid or credited.', + }, + SI_PAID_NOT_PAYABLE: { + httpStatus: 400, + message_sv: 'Leverantörsfakturan kan inte markeras som betald i nuvarande status.', + message_en: 'Supplier invoice is not in a payable state.', + }, + SI_PAID_PERIOD_LOCKED: { + httpStatus: 400, + message_sv: 'Bokföringen är låst. Betalningen kan inte registreras.', + message_en: 'Bookkeeping is locked; payment cannot be recorded.', + }, + SI_PAID_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte registrera betalningen.', + message_en: 'Failed to record supplier invoice payment.', + }, + SI_CREDIT_ALREADY_CREDITED: { + httpStatus: 409, + message_sv: 'Leverantörsfakturan har redan krediterats.', + message_en: 'Supplier invoice has already been credited.', + }, + SI_CREDIT_PERIOD_LOCKED: { + httpStatus: 400, + message_sv: 'Bokföringen är låst. Krediteringen kan inte skapas.', + message_en: 'Bookkeeping is locked; credit note cannot be created.', + }, + SI_CREDIT_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte kreditera leverantörsfakturan.', + message_en: 'Failed to credit supplier invoice.', + }, +} + +const SALARY: Record = { + SALARY_RUN_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Lönekörningen kunde inte hittas.', + message_en: 'Salary run not found.', + }, + SALARY_RUN_NO_EMPLOYEES: { + httpStatus: 400, + message_sv: 'Inga aktiva anställda finns i företaget.', + message_en: 'No active employees in the company.', + }, + SALARY_RUN_TAX_TABLE_MISSING: { + httpStatus: 400, + message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.', + message_en: 'Tax table is missing for the period.', + }, + SALARY_RUN_PERIOD_LOCKED: { + httpStatus: 400, + message_sv: 'Lönekörningen kan inte göras i en låst period.', + message_en: 'Salary run cannot be processed in a locked period.', + }, + SALARY_RUN_NOT_CALCULATED: { + httpStatus: 400, + message_sv: 'Lönekörningen måste beräknas innan bokföring.', + message_en: 'Salary run must be calculated before booking.', + }, + SALARY_RUN_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Lönekörningen kunde inte skapas.', + message_en: 'Failed to create salary run.', + }, + SALARY_RUN_CALCULATE_FAILED: { + httpStatus: 500, + message_sv: 'Lönekörningen kunde inte beräknas.', + message_en: 'Failed to calculate salary run.', + }, + SALARY_RUN_BOOK_FAILED: { + httpStatus: 500, + message_sv: 'Lönekörningen kunde inte bokföras.', + message_en: 'Failed to book salary run.', + }, + AGI_NO_SALARY_RUN: { + httpStatus: 400, + message_sv: 'Det finns ingen lönekörning för perioden.', + message_en: 'No salary run exists for the period.', + }, + AGI_FSKATT_VERIFICATION_FAILED: { + httpStatus: 400, + message_sv: 'F-skattekontrollen misslyckades. Kontrollera leverantörens F-skatt.', + message_en: 'F-skatt verification failed.', + }, + AGI_GENERATION_FAILED: { + httpStatus: 500, + message_sv: 'AGI-deklarationen kunde inte genereras.', + message_en: 'Failed to generate AGI declaration.', + }, +} + +const COMPANY: Record = { + COMPANY_CREATE_DUPLICATE_ORG_NUMBER: { + httpStatus: 409, + message_sv: 'Ett företag med samma organisationsnummer finns redan.', + message_en: 'A company with that organisation number already exists.', + }, + COMPANY_CREATE_BAS_SEED_FAILED: { + httpStatus: 500, + message_sv: 'Kontoplanen kunde inte skapas. Försök igen.', + message_en: 'Failed to seed the chart of accounts.', + }, + COMPANY_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Företaget kunde inte skapas.', + message_en: 'Failed to create company.', + }, +} + +const API_KEY: Record = { + API_KEY_SCOPE_INVALID: { + httpStatus: 400, + message_sv: 'En eller flera scopes är ogiltiga.', + message_en: 'One or more requested scopes are invalid.', + }, + API_KEY_QUOTA_EXCEEDED: { + httpStatus: 429, + message_sv: 'Du har nått maxgränsen för antal API-nycklar.', + message_en: 'API key quota exceeded.', + }, + API_KEY_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'API-nyckeln kunde inte skapas.', + message_en: 'Failed to create API key.', + }, + API_KEY_REVOKE_FAILED: { + httpStatus: 500, + message_sv: 'API-nyckeln kunde inte återkallas.', + message_en: 'Failed to revoke API key.', + }, + API_KEY_NOT_FOUND: { + httpStatus: 404, + message_sv: 'API-nyckeln kunde inte hittas.', + message_en: 'API key not found.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Provider connection / external HTTP codes +// ───────────────────────────────────────────────────────────────── + +const PROVIDER: Record = { + PROVIDER_AUTH_EXPIRED: { + httpStatus: 401, + message_sv: 'Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.', + message_en: 'Provider authentication expired or refresh failed.', + }, + PROVIDER_RATE_LIMITED: { + httpStatus: 429, + message_sv: + 'Leverantören begränsar antalet anrop just nu. Vänta en stund och försök igen.', + message_en: 'Provider rate limit exceeded.', + }, + PROVIDER_UNREACHABLE: { + httpStatus: 502, + message_sv: 'Leverantörens tjänst är inte tillgänglig just nu. Försök igen om en stund.', + message_en: 'Provider service is unreachable (network/DNS error).', + }, + PROVIDER_UPSTREAM_ERROR: { + httpStatus: 502, + message_sv: 'Leverantören svarade med ett fel. Försök igen om en stund.', + message_en: 'Provider returned an upstream 5xx error.', + }, +} + +// ───────────────────────────────────────────────────────────────── +// Combined registry +// ───────────────────────────────────────────────────────────────── + +const REGISTRY: Record = { + ...GENERIC, + ...BOOKKEEPING, + ...TRANSACTIONS, + ...MATCH_INVOICE, + ...MATCH_SI, + ...INVOICE, + ...SUPPLIER_INVOICE, + ...PERIOD, + ...YEAR_END, + ...OPENING_BAL, + ...FX, + ...REPORT, + ...VAT_REPORT, + ...SIE_EXPORT, + ...TAX_DECL, + ...SIE_IMPORT, + ...BANK_FILE, + ...OPENING_BALANCE_IMPORT, + ...PROVIDER_MIGRATION, + ...DOCUMENT, + ...CUSTOMER, + ...SUPPLIER, + ...SUPPLIER_INVOICE_WAVE4, + ...SALARY, + ...COMPANY, + ...API_KEY, + ...PROVIDER, +} + +export function getErrorEntry(code: string): StructuredErrorEntry | undefined { + return REGISTRY[code] +} + +export function hasErrorEntry(code: string): boolean { + return code in REGISTRY +} + +/** + * Test-only: returns all registered codes. Used by the unit test that asserts + * the matrix in the plan file stays in sync with this registry. + */ +export function listErrorCodes(): string[] { + return Object.keys(REGISTRY) +} diff --git a/lib/events/bus.ts b/lib/events/bus.ts index bdf2faf9..162da5fe 100644 --- a/lib/events/bus.ts +++ b/lib/events/bus.ts @@ -1,23 +1,25 @@ import type { CoreEvent, CoreEventType, EventHandler } from './types' +import { createLogger } from '@/lib/logger' // Internal handler type — loose enough for the Map, but type-safe at the public API // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyHandler = (payload: any) => Promise | void +const log = createLogger('event-bus') + /** * In-process event bus. * * - Handlers run concurrently via Promise.allSettled (failing handler never crashes emitter) * - Module-level singleton (persists across requests in same process) * - One-way: core services emit, extensions subscribe + * - Rejected handlers are logged with structured fields so they're greppable + * in Vercel logs by event type, handler name, and the originating request id + * (when carried in the payload). */ class EventBus { private handlers = new Map>() - /** - * Subscribe to an event type. - * Returns an unsubscribe function. - */ on( eventType: T, handler: EventHandler @@ -37,31 +39,33 @@ class EventBus { } } - /** - * Emit an event to all registered handlers. - * Uses Promise.allSettled so a failing handler never crashes the emitter. - */ async emit(event: CoreEvent): Promise { const handlerSet = this.handlers.get(event.type) if (!handlerSet || handlerSet.size === 0) return + const handlers = [...handlerSet] const results = await Promise.allSettled( - [...handlerSet].map((handler) => handler(event.payload)) + handlers.map((handler) => handler(event.payload)) ) - for (const result of results) { + for (let i = 0; i < results.length; i++) { + const result = results[i] if (result.status === 'rejected') { - console.error( - `[EventBus] Handler failed for "${event.type}":`, - result.reason - ) + const handler = handlers[i] + const handlerName = handler.name || 'anonymous' + const payload = event.payload as Record + + log.error('handler failed', result.reason, { + eventType: event.type, + handler: handlerName, + companyId: typeof payload.companyId === 'string' ? payload.companyId : undefined, + userId: typeof payload.userId === 'string' ? payload.userId : undefined, + }) } } } - /** - * Remove all handlers (useful for testing). - */ + /** Remove all handlers (useful for testing). */ clear(): void { this.handlers.clear() } diff --git a/lib/extensions/context-factory.ts b/lib/extensions/context-factory.ts index 23de5cda..12de1fe6 100644 --- a/lib/extensions/context-factory.ts +++ b/lib/extensions/context-factory.ts @@ -12,10 +12,13 @@ import type { } from './types' /** - * Create a prefixed logger for an extension. + * Create a prefixed logger for an extension. When `bind` is supplied the + * fields (e.g. requestId, userId, companyId) are merged into every log line. */ -function createExtLogger(extensionId: string): ExtensionLogger { - const logger = createLogger(`ext:${extensionId}`) +function createExtLogger(extensionId: string, bind?: Record): ExtensionLogger { + const logger = bind + ? createLogger(`ext:${extensionId}`, bind) + : createLogger(`ext:${extensionId}`) return { info: (message: string, ...args: unknown[]) => logger.info(message, ...args), warn: (message: string, ...args: unknown[]) => logger.warn(message, ...args), @@ -106,22 +109,31 @@ function createServices(): ExtensionServices { * * The context gives extensions access to Supabase, event emission, settings, * storage, logging, and core services — without importing from core modules. + * + * `requestId` (when supplied by the dispatcher) flows through the bound logger + * and is exposed on the context so handlers can pass it into + * `errorResponseFromCode(...)` for the envelope + `X-Request-Id` header. */ export function createExtensionContext( supabase: SupabaseClient, userId: string, companyId: string, - extensionId: string + extensionId: string, + requestId?: string, ): ExtensionContext { + const logBindings: Record = { userId, companyId, extensionId } + if (requestId) logBindings.requestId = requestId + return { userId, companyId, extensionId, + requestId, supabase, emit: (event: CoreEvent) => eventBus.emit(event), settings: createSettings(supabase, userId, companyId, extensionId), storage: createStorage(supabase), - log: createExtLogger(extensionId), + log: createExtLogger(extensionId, logBindings), services: createServices(), } } diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index 1253a528..c29adefb 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -168,6 +168,12 @@ export interface ExtensionContext { userId: string companyId: string extensionId: string + /** + * Stable id for the inbound HTTP request — `req_`. + * Included in the response envelope and in the `X-Request-Id` header so + * support staff can grep stdout logs by it. + */ + requestId?: string supabase: SupabaseClient emit(event: CoreEvent): Promise settings: ExtensionSettings diff --git a/lib/hooks/use-error-toast.ts b/lib/hooks/use-error-toast.ts new file mode 100644 index 00000000..768a5194 --- /dev/null +++ b/lib/hooks/use-error-toast.ts @@ -0,0 +1,114 @@ +'use client' + +/** + * Client-side helper that turns a fetch failure into a Swedish toast with + * remediation hint and the X-Request-Id for support reference. + * + * Accepts: + * - the `{ error: {...} }` envelope produced by the route wrapper + * - a Response object (the helper reads it for you) + * - a raw Error / string (falls back to getErrorMessage) + * + * Usage: + * const showError = useErrorToast() + * const res = await fetch('/api/invoices/123/send', { method: 'POST' }) + * if (!res.ok) { + * await showError(res, { context: 'invoice' }) + * return + * } + */ + +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import type { ErrorEnvelope } from '@/lib/errors/get-structured-error' + +type ErrorContext = + | 'invoice' + | 'supplier_invoice' + | 'customer' + | 'supplier' + | 'transaction' + | 'journal_entry' + | 'settings' + | 'auth' + | 'salary' + +interface ShowErrorOptions { + context?: ErrorContext + /** Override the toast title (Swedish summary). Defaults to envelope.message. */ + title?: string +} + +interface NormalizedError { + message: string + remediation?: string + requestId?: string + code?: string +} + +async function normalize(input: unknown): Promise { + // Response: try to read JSON body and X-Request-Id header + if (input instanceof Response) { + const requestId = input.headers.get('X-Request-Id') ?? undefined + let body: unknown = null + try { + body = await input.json() + } catch { + // ignore — body might be empty + } + const fromBody = readEnvelope(body) + return { + message: fromBody.message ?? getErrorMessage(body, { statusCode: input.status }), + remediation: fromBody.remediation, + requestId: fromBody.requestId ?? requestId, + code: fromBody.code, + } + } + + const fromBody = readEnvelope(input) + if (fromBody.message) { + return fromBody + } + return { message: getErrorMessage(input) } +} + +function readEnvelope(input: unknown): NormalizedError { + if (!input || typeof input !== 'object') return { message: '' } + const obj = input as Record + const errObj = obj.error + if (errObj && typeof errObj === 'object') { + const e = errObj as Partial + return { + message: typeof e.message === 'string' ? e.message : '', + remediation: + e.remediation && typeof e.remediation === 'object' + ? (e.remediation as { description?: string }).description + : undefined, + requestId: typeof e.requestId === 'string' ? e.requestId : undefined, + code: typeof e.code === 'string' ? e.code : undefined, + } + } + return { message: '' } +} + +export function useErrorToast() { + const { toast } = useToast() + + return async function showError(input: unknown, options: ShowErrorOptions = {}) { + const norm = await normalize(input) + const title = options.title ?? norm.message ?? getErrorMessage(input, { context: options.context }) + + const descriptionParts: string[] = [] + if (norm.remediation) descriptionParts.push(norm.remediation) + if (norm.requestId) descriptionParts.push(`Felreferens: ${norm.requestId}`) + if (process.env.NODE_ENV !== 'production' && norm.code) { + descriptionParts.push(`Kod: ${norm.code}`) + } + + toast({ + variant: 'destructive', + title, + description: descriptionParts.length > 0 ? descriptionParts.join(' · ') : undefined, + }) + } +} diff --git a/lib/logger.ts b/lib/logger.ts index f70bdb71..4c976b3e 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,31 +1,227 @@ /** - * Lightweight structured logger for server-side code. + * Structured logger for server-side code. * - * Wraps console.* with module prefixes and environment-aware filtering. - * Suppresses info/warn in test environment to reduce noise. - * Can be swapped for an external logging service (e.g. Axiom, Datadog) later. + * Emits JSON in production (Vercel logs ingest these), pretty text in dev. + * Suppresses info/warn in test (preserves existing test-noise contract). + * + * Backward-compatible with the legacy `log.error(msg, ...args)` callers — any + * extra args after the message are merged into the structured payload: + * - Error instances become `err: { name, message, stack, code }` + * - plain objects merge into the context fields (after PII redaction) + * - everything else goes into `details: [...]` + * + * New code should prefer the explicit ctx form: `log.error('msg', err, ctx)`. + * + * Use `log.child({ requestId, companyId, ... })` to bind a context that is + * merged into every subsequent call. The `with-route-context` wrapper relies + * on this to thread requestId through a request lifecycle. */ type LogLevel = 'info' | 'warn' | 'error' -function shouldLog(level: LogLevel): boolean { - if (process.env.NODE_ENV === 'test') { - return level === 'error' +export interface LogContext { + requestId?: string + userId?: string + companyId?: string + operation?: string + entityType?: string + entityId?: string + durationMs?: number + [k: string]: unknown +} + +export interface Logger { + info(message: string, ...args: unknown[]): void + warn(message: string, ...args: unknown[]): void + error(message: string, ...args: unknown[]): void + child(extra: LogContext): Logger +} + +const REDACTED = '[REDACTED]' + +const REDACT_KEYS = new Set([ + 'password', + 'token', + 'access_token', + 'refresh_token', + 'apikey', + 'api_key', + 'secret', + 'authorization', + 'cookie', + 'bank_account', + 'bankaccount', + 'iban', + 'personnummer', + 'ssn', + 'credentials', +]) + +const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +const PERSONNUMMER_PATTERN = /\b\d{6}-?\d{4}\b|\b\d{8}-?\d{4}\b/ + +function redactString(value: string): string { + // Strip UUIDs first to avoid false-positive personnummer matches + const stripped = value.replace(UUID_PATTERN, '') + if (PERSONNUMMER_PATTERN.test(stripped)) { + return REDACTED } + return value +} + +function redact(value: unknown, keyPath = ''): unknown { + if (value === null || value === undefined) return value + if (typeof value === 'string') return redactString(value) + if (typeof value === 'number' || typeof value === 'boolean') return value + if (value instanceof Date) return value.toISOString() + if (value instanceof Error) { + return { + name: value.name, + message: redactString(value.message), + stack: process.env.NODE_ENV === 'production' ? undefined : value.stack, + code: (value as Error & { code?: unknown }).code, + } + } + if (Array.isArray(value)) return value.map((v, i) => redact(v, `${keyPath}[${i}]`)) + if (typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (REDACT_KEYS.has(k.toLowerCase())) { + out[k] = REDACTED + } else { + out[k] = redact(v, keyPath ? `${keyPath}.${k}` : k) + } + } + return out + } + return value +} + +function isPlainObject(v: unknown): v is Record { + return ( + typeof v === 'object' && + v !== null && + !(v instanceof Error) && + !Array.isArray(v) && + !(v instanceof Date) && + Object.getPrototypeOf(v) === Object.prototype + ) +} + +function shouldLog(level: LogLevel): boolean { + if (process.env.NODE_ENV === 'test') return level === 'error' return true } -export function createLogger(module: string) { +interface LogRecord { + level: LogLevel + module: string + msg: string + ts: string + err?: unknown + details?: unknown[] + [k: string]: unknown +} + +function buildRecord( + level: LogLevel, + module: string, + base: LogContext, + message: string, + args: unknown[], +): LogRecord { + const ctx: Record = { ...base } + let err: unknown + const details: unknown[] = [] + + for (const arg of args) { + if (arg instanceof Error) { + // First Error wins; subsequent ones land in details + if (err === undefined) err = redact(arg) + else details.push(redact(arg)) + } else if (isPlainObject(arg)) { + Object.assign(ctx, redact(arg) as Record) + } else if (arg !== undefined) { + details.push(redact(arg)) + } + } + + const record: LogRecord = { + level, + module, + msg: redactString(message), + ts: new Date().toISOString(), + ...(redact(ctx) as Record), + } + if (err !== undefined) record.err = err + if (details.length > 0) record.details = details + return record +} + +function emit(record: LogRecord) { + const fn = + record.level === 'error' ? console.error : record.level === 'warn' ? console.warn : console.log + + if (process.env.NODE_ENV === 'production') { + fn(JSON.stringify(record)) + return + } + + // Pretty dev output + const { level, module, msg, ts: _ts, err, details, ...ctx } = record + const ctxKeys = Object.keys(ctx) + const ctxStr = ctxKeys.length > 0 ? ' ' + ctxKeys.map((k) => `${k}=${JSON.stringify(ctx[k])}`).join(' ') : '' const prefix = `[${module}]` + const tag = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO' + fn(`${prefix} ${tag} ${msg}${ctxStr}`) + if (err) fn(' err:', err) + if (details && details.length > 0) fn(' details:', ...details) +} + +function makeLogger(module: string, base: LogContext): Logger { return { info(message: string, ...args: unknown[]) { - if (shouldLog('info')) console.log(prefix, message, ...args) + if (!shouldLog('info')) return + emit(buildRecord('info', module, base, message, args)) }, warn(message: string, ...args: unknown[]) { - if (shouldLog('warn')) console.warn(prefix, message, ...args) + if (!shouldLog('warn')) return + emit(buildRecord('warn', module, base, message, args)) }, error(message: string, ...args: unknown[]) { - if (shouldLog('error')) console.error(prefix, message, ...args) + if (!shouldLog('error')) return + emit(buildRecord('error', module, base, message, args)) + }, + child(extra: LogContext): Logger { + return makeLogger(module, { ...base, ...extra }) + }, + } +} + +export function createLogger(module: string, base: LogContext = {}): Logger { + return makeLogger(module, base) +} + +/** + * Test-only escape hatch. Returns a logger that writes records to the supplied + * array instead of stdout. Useful for asserting on emitted log lines. + */ +export function createTestLogger(module: string, sink: LogRecord[], base: LogContext = {}): Logger { + const push = (level: LogLevel, message: string, args: unknown[]) => { + sink.push(buildRecord(level, module, base, message, args)) + } + return { + info(message: string, ...args: unknown[]) { + push('info', message, args) + }, + warn(message: string, ...args: unknown[]) { + push('warn', message, args) + }, + error(message: string, ...args: unknown[]) { + push('error', message, args) + }, + child(extra: LogContext): Logger { + return createTestLogger(module, sink, { ...base, ...extra }) }, } } diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index eb62c6e9..f4a9c453 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -126,6 +126,74 @@ describe('commitPendingOperation: unlock_period', () => { }) }) +// ─── create_transaction ───────────────────────────────────────────── + +describe('commitPendingOperation: create_transaction', () => { + it('happy path: inserts a transactions row with import_source=mcp and returns the id', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'tx-42' }, error: null }) // executor insert + enqueue({ data: null, error: null }) // dispatcher's update + + const op = makePendingOp({ + operation_type: 'create_transaction', + params: { + date: '2026-05-01', + amount: -129.5, + description: 'AWS subscription', + currency: 'USD', + external_id: 'recAirtable123', + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ transaction_id: 'tx-42' }) + }) + + it('rejects with 400 when required fields are missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'create_transaction', + params: { date: '2026-05-01' }, // missing amount + description + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + }) + + it('returns 409 when external_id collides with an existing row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } as never }) // executor insert + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'create_transaction', + params: { + date: '2026-05-01', + amount: 100, + description: 'test', + external_id: 'recAirtable123', + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 409 collisions are treated as auto-rejected by the dispatcher. + expect(result.status).toBe('rejected') + expect(result.auto_rejected).toBe(true) + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/already exists/) + }) +}) + // ─── import_sie ───────────────────────────────────────────────────── describe('commitPendingOperation: import_sie', () => { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 027652ce..057d092e 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -306,6 +306,52 @@ async function commitCreateCustomer( return { data: { customer_id: data.id } } } +async function commitCreateTransaction( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const date = params.date as string + const amount = Number(params.amount) + const description = (params.description as string) ?? '' + const currency = ((params.currency as string) || 'SEK') as Currency + const bankConnectionId = (params.bank_connection_id as string) || null + const externalId = (params.external_id as string) || null + + if (!date || !description.trim() || !Number.isFinite(amount)) { + return { error: 'date, description, and amount are required', status: 400 } + } + + const { data, error } = await supabase + .from('transactions') + .insert({ + user_id: userId, + company_id: companyId, + bank_connection_id: bankConnectionId, + external_id: externalId, + date, + description: description.trim(), + amount, + currency, + import_source: 'mcp', + }) + .select('id') + .single() + + if (error) { + const isDuplicate = error.code === '23505' + return { + error: isDuplicate + ? `A transaction with external_id "${externalId}" already exists.` + : error.message, + status: isDuplicate ? 409 : 500, + } + } + + return { data: { transaction_id: data.id } } +} + async function commitCreateInvoice( supabase: SupabaseClient, userId: string, @@ -1600,6 +1646,9 @@ export async function commitPendingOperation( case 'create_invoice': result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params) break + case 'create_transaction': + result = await commitCreateTransaction(supabase, userId, companyId, pendingOp.params) + break case 'mark_invoice_paid': result = await commitMarkInvoicePaid(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index cb41f9f3..d77d482f 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -26,6 +26,7 @@ export const OPERATION_RISK_TIERS: Record = { categorize_transaction: 'medium', match_transaction_invoice: 'medium', create_invoice: 'medium', // creates as draft; sending is a separate op + create_transaction: 'medium', // ingests an uncategorized row; reversible by delete // Pinning a doc to a tx is reversible while pre-categorization, but the link // becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize // propagates it. A wrong attachment requires a rättelse, so require human diff --git a/lib/providers/with-provider-call.ts b/lib/providers/with-provider-call.ts new file mode 100644 index 00000000..ce627268 --- /dev/null +++ b/lib/providers/with-provider-call.ts @@ -0,0 +1,208 @@ +/** + * Wraps a single external HTTP call to a third-party provider (Fortnox, Bokio, + * Visma, Briox, BL/Björn Lundén, Enable Banking, etc.) with structured + * logging and code-mapped errors. + * + * Translates HTTP failures and network errors into ProviderCallError, which + * the route wrapper's errorResponse() recognises as a structured code. This + * keeps the user message + remediation consistent across providers without + * each call site having to repeat the mapping. + */ + +import { createLogger, type Logger } from '@/lib/logger' + +export type ProviderCallErrorCode = + | 'PROVIDER_AUTH_EXPIRED' + | 'PROVIDER_RATE_LIMITED' + | 'PROVIDER_UNREACHABLE' + | 'PROVIDER_UPSTREAM_ERROR' + +export class ProviderCallError extends Error { + readonly code: ProviderCallErrorCode + readonly provider: string + readonly status?: number + readonly retryAfterSeconds?: number + + constructor( + code: ProviderCallErrorCode, + provider: string, + message: string, + extras: { status?: number; retryAfterSeconds?: number } = {}, + ) { + super(message) + this.name = 'ProviderCallError' + this.code = code + this.provider = provider + this.status = extras.status + this.retryAfterSeconds = extras.retryAfterSeconds + } +} + +export function isProviderCallError(err: unknown): err is ProviderCallError { + return err instanceof ProviderCallError +} + +interface ProviderCallOptions { + /** Provider id ('fortnox', 'bokio', 'visma', etc.). */ + provider: string + /** Short label for what this call does, e.g. 'fetch_invoices'. */ + operation: string + /** Optional logger; if omitted a `provider/` logger is created. */ + log?: Logger + /** Extra context merged into the log line. */ + context?: Record +} + +/** + * Run an async callable that performs the actual HTTP request and translate + * its failures. The callable should throw a `Response` (preferred) or a + * regular Error; ProviderCallError is mapped from the response status. + * + * Example: + * await withProviderCall( + * { provider: 'fortnox', operation: 'fetch_invoices' }, + * async () => { + * const res = await fetch(url, { headers }) + * if (!res.ok) throw res + * return res.json() + * }, + * ) + */ +export async function withProviderCall( + options: ProviderCallOptions, + call: () => Promise, +): Promise { + const log = (options.log ?? createLogger(`provider/${options.provider}`)).child({ + provider: options.provider, + providerOp: options.operation, + ...options.context, + }) + + const start = Date.now() + try { + const result = await call() + log.info('provider call ok', { latencyMs: Date.now() - start }) + return result + } catch (raw) { + const latencyMs = Date.now() - start + + if (raw instanceof Response) { + const mapped = mapResponseError(raw, options.provider) + log.error('provider call failed (http)', mapped, { + latencyMs, + status: raw.status, + }) + throw mapped + } + + if (raw instanceof ProviderCallError) { + log.error('provider call failed', raw, { latencyMs }) + throw raw + } + + if (raw instanceof Error && isNetworkError(raw)) { + const wrapped = new ProviderCallError( + 'PROVIDER_UNREACHABLE', + options.provider, + raw.message, + ) + log.error('provider call unreachable', wrapped, { latencyMs }) + throw wrapped + } + + // Unknown shape — re-throw so the outer handler can decide. We still log it. + log.error('provider call failed (unknown)', raw as Error, { latencyMs }) + throw raw + } +} + +function mapResponseError(res: Response, provider: string): ProviderCallError { + if (res.status === 401 || res.status === 403) { + return new ProviderCallError( + 'PROVIDER_AUTH_EXPIRED', + provider, + `Provider authentication failed: ${res.status} ${res.statusText}`, + { status: res.status }, + ) + } + if (res.status === 429) { + const retryAfter = parseRetryAfter(res.headers.get('retry-after')) + return new ProviderCallError( + 'PROVIDER_RATE_LIMITED', + provider, + `Provider rate limit hit: ${res.status} ${res.statusText}`, + { status: res.status, retryAfterSeconds: retryAfter }, + ) + } + if (res.status >= 500) { + return new ProviderCallError( + 'PROVIDER_UPSTREAM_ERROR', + provider, + `Provider upstream error: ${res.status} ${res.statusText}`, + { status: res.status }, + ) + } + // 4xx other than 401/403/429 is application-level — surface as upstream so + // the user gets a meaningful Swedish message; the actual cause is in logs. + return new ProviderCallError( + 'PROVIDER_UPSTREAM_ERROR', + provider, + `Provider rejected request: ${res.status} ${res.statusText}`, + { status: res.status }, + ) +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined + const n = parseInt(value, 10) + return Number.isFinite(n) ? n : undefined +} + +function isNetworkError(err: Error): boolean { + // node-undici throws TypeError('fetch failed') with a `cause` for DNS/TCP issues. + if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) return true + if (err.name === 'AbortError') return true + // Known undici error codes + const cause = (err as Error & { cause?: { code?: string } }).cause + if (cause?.code && ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(cause.code)) { + return true + } + return false +} + +/** + * Classify an error from a provider client (Fortnox/Bokio/Visma/Briox/BL) into + * a structured error code. Reads `statusCode` (Fortnox client) or `status` + * (other clients) off the thrown error and maps: + * + * 401/403 → PROVIDER_AUTH_EXPIRED + * 429 → PROVIDER_RATE_LIMITED + * 5xx → PROVIDER_UPSTREAM_ERROR + * network → PROVIDER_UNREACHABLE + * other → null (caller falls back to its domain-specific code, e.g. + * `PROVIDER_SIE_FETCH_FAILED`) + * + * Use at the boundary where a provider call's failure becomes a user-facing + * response. Lets the toast show a specific Swedish message ("Anslutningen har + * gått ut. Återanslut för att fortsätta." vs. "Försök igen om en stund.") + * instead of the same generic message for every cause. + */ +export function classifyProviderError(error: unknown): ProviderCallErrorCode | null { + if (error instanceof ProviderCallError) { + return error.code + } + if (!(error instanceof Error)) return null + + const status = + (error as Error & { statusCode?: number; status?: number }).statusCode ?? + (error as Error & { statusCode?: number; status?: number }).status + + if (typeof status === 'number') { + if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED' + if (status === 429) return 'PROVIDER_RATE_LIMITED' + if (status >= 500) return 'PROVIDER_UPSTREAM_ERROR' + } + if (isNetworkError(error)) return 'PROVIDER_UNREACHABLE' + + return null +} diff --git a/supabase/migrations/20260505160000_fix_corrupted_bas_account_names.sql b/supabase/migrations/20260505160000_fix_corrupted_bas_account_names.sql new file mode 100644 index 00000000..b2aea66f --- /dev/null +++ b/supabase/migrations/20260505160000_fix_corrupted_bas_account_names.sql @@ -0,0 +1,102 @@ +-- Fix corrupted BAS chart-of-accounts names in already-seeded companies. +-- +-- A chart-data import bug left ~69 BAS accounts in lib/bookkeeping/bas-data/class-*.ts +-- with account_name and description corrupted by the next group's header (e.g. +-- "Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, ..."). The +-- TypeScript source has been fixed; this migration is a safety net that cleans up +-- any chart_of_accounts rows that absorbed those strings via SIE import, AI +-- account suggestions, or manual creation. +-- +-- The WHERE account_name = guard preserves user-customized names. +-- The CASE on description protects rows where users only customized the description. +-- Idempotent: re-running matches zero rows. + +with fixes (account_number, corrupted_name, fixed_name) as ( + values + -- Class 1 + ('1099', 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar 11 BYGGNADER OCH MARK', 'Ackumulerade avskrivningar på övriga immateriella anläggningstillgångar'), + ('1188', 'Förskott för byggnader och mark 12 MASKINER RESPEKTIVE INVENTARIER', 'Förskott för byggnader och mark'), + ('1299', 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar 13 FINANSIELLA ANLÄGGNINGSTILLGÅNGAR', 'Ackumulerade avskrivningar på övriga materiella anläggningstillgångar'), + ('1389', 'Ackumulerade nedskrivningar av andra långfristiga fordringar 14 LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', 'Ackumulerade nedskrivningar av andra långfristiga fordringar'), + ('1493', 'Djur som klassificeras som omsättningstillgång 15 KUNDFORDRINGAR', 'Djur som klassificeras som omsättningstillgång'), + ('1573', 'Kundfordringar hos övriga företag som det finns ett ägarintresse i 16 ÖVRIGA KORTFRISTIGA FORDRINGAR', 'Kundfordringar hos övriga företag som det finns ett ägarintresse i'), + ('1690', 'Fordringar för tecknat men ej inbetalt aktiekapital 17 FÖRUTBETALDA KOSTNADER OCH UPPLUPNA INTÄKTER', 'Fordringar för tecknat men ej inbetalt aktiekapital'), + ('1790', 'Övriga förutbetalda kostnader och upplupna intäkter 18 KORTFRISTIGA PLACERINGAR', 'Övriga förutbetalda kostnader och upplupna intäkter'), + ('1890', 'Nedskrivning av kortfristiga placeringar 19 KASSA OCH BANK', 'Nedskrivning av kortfristiga placeringar'), + ('1990', 'Redovisningsmedel 20 EGET KAPITAL', 'Redovisningsmedel'), + -- Class 2 + ('2099', 'Årets resultat 21 OBESKATTADE RESERVER', 'Årets resultat'), + ('2199', 'Övriga obeskattade reserver 22 AVSÄTTNINGAR', 'Övriga obeskattade reserver'), + ('2290', 'Övriga avsättningar 23 LÅNGFRISTIGA SKULDER', 'Övriga avsättningar'), + ('2399', 'Övriga långfristiga skulder 24 KORTFRISTIGA SKULDER TILL KREDITINSTITUT, KUNDER OCH LEVERANTÖRER', 'Övriga långfristiga skulder'), + ('2499', 'Andra övriga kortfristiga skulder 25 SKATTESKULDER', 'Andra övriga kortfristiga skulder'), + ('2518', 'Betald F-skatt 26 MOMS OCH PUNKTSKATTER', 'Betald F-skatt'), + ('2670', 'Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG', 'Utgående moms på försäljning inom EU, OSS'), + ('2799', 'Övriga löneavdrag 28 ÖVRIGA KORTFRISTIGA SKULDER', 'Övriga löneavdrag'), + ('2899', 'Övriga kortfristiga skulder 29 UPPLUPNA KOSTNADER OCH FÖRUTBETALDA INTÄKTER', 'Övriga kortfristiga skulder'), + ('2999', 'OBS-konto 30 HUVUDINTÄKTER', 'OBS-konto'), + -- Class 3 + ('3404', 'Egna uttag, momsfria 35 FAKTURERADE KOSTNADER', 'Egna uttag, momsfria'), + ('3590', 'Övriga fakturerade kostnader 36 RÖRELSENS SIDOINTÄKTER', 'Övriga fakturerade kostnader'), + ('3690', 'Övriga sidointäkter 37 INTÄKTSKORRIGERINGAR', 'Övriga sidointäkter'), + ('3790', 'Övriga intäktskorrigeringar 38 AKTIVERAT ARBETE FÖR EGEN RÄKNING', 'Övriga intäktskorrigeringar'), + ('3870', 'Aktiverat arbete (personal) 39 ÖVRIGA RÖRELSEINTÄKTER', 'Aktiverat arbete (personal)'), + ('3999', 'Övriga rörelseintäkter 40 INKÖP AV HANDELSVAROR', 'Övriga rörelseintäkter'), + -- Class 4 + ('4099', 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB', 'Övriga reduktioner av inköpspriser (Handelsvaror)'), + ('4212', 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', 'Sålda handelsvaror negativ VMB 25 %'), + ('4310', 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)', 'Inköp av råvaror och material i Sverige'), + ('4427', 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)', 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms'), + ('4547', 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)', 'Import av råvaror och material, 6 % moms'), + ('4670', 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)', 'Inköp av legoarbeten'), + ('4739', 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)', 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter)'), + ('4890', 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN', 'Övriga produktionskostnader (Råvaror och förnödenheter)'), + ('4988', 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER', 'Återföring av nedskrivning av värdepapper (Handelsvaror)'), + -- Class 5 + ('5090', 'Övriga lokalkostnader 51 FASTIGHETSKOSTNADER', 'Övriga lokalkostnader'), + ('5198', 'Övriga fastighetskostnader 52 HYRA AV ANLÄGGNINGSTILLGÅNGAR', 'Övriga fastighetskostnader'), + ('5290', 'Hyra av övriga anläggningstillgångar, ej datorer och fordon 53 ENERGIKOSTNADER FÖR DRIFT (EJ RÅVAROR OCH FÖRNÖDENHETER)', 'Hyra av övriga anläggningstillgångar, ej datorer och fordon'), + ('5390', 'Övriga energikostnader för drift (ej råvaror och förnödenheter) 54 FÖRBRUKNINGSINVENTARIER OCH FÖRBRUKNINGSMATERIAL', 'Övriga energikostnader för drift (ej råvaror och förnödenheter)'), + ('5480', 'Arbetskläder och skyddsmaterial 55 REPARATION OCH UNDERHÅLL', 'Arbetskläder och skyddsmaterial'), + ('5590', 'Övriga kostnader för reparation och underhåll 56 KOSTNADER FÖR TRANSPORTMEDEL', 'Övriga kostnader för reparation och underhåll'), + ('5699', 'Övriga kostnader för övriga transportmedel 57 FRAKTER OCH TRANSPORTER', 'Övriga kostnader för övriga transportmedel'), + ('5790', 'Övriga kostnader för frakter och transporter 58 RESEKOSTNADER', 'Övriga kostnader för frakter och transporter'), + ('5890', 'Övriga resekostnader 59 REKLAM OCH PR', 'Övriga resekostnader'), + ('5990', 'Övriga kostnader för reklam och PR 60 ÖVRIGA FÖRSÄLJNINGSKOSTNADER', 'Övriga kostnader för reklam och PR'), + -- Class 6 + ('6090', 'Övriga försäljningskostnader 61 KONTORSMATERIAL OCH TRYCKSAKER', 'Övriga försäljningskostnader'), + ('6150', 'Trycksaker 62 TELE, DATA OCH POST', 'Trycksaker'), + ('6290', 'Övriga tele-, data- och postkostnader 63 FÖRETAGSFÖRSÄKRINGAR OCH ÖVRIGA RISKKOSTNADER', 'Övriga tele-, data- och postkostnader'), + ('6392', 'Övriga riskkostnader, ej avdragsgilla 64 FÖRVALTNINGSKOSTNADER', 'Övriga riskkostnader, ej avdragsgilla'), + ('6490', 'Övriga förvaltningskostnader 65 ÖVRIGA EXTERNA TJÄNSTER', 'Övriga förvaltningskostnader'), + ('6590', 'Övriga externa tjänster 67 SÄRSKILT FÖR IDEELLA FÖRENINGAR OCH STIFTELSER', 'Övriga externa tjänster'), + ('6710', 'Lämnade bidrag 68 INHYRD PERSONAL', 'Lämnade bidrag'), + ('6890', 'Övrig inhyrd personal 69 ÖVRIGA EXTERNA KOSTNADER', 'Övrig inhyrd personal'), + ('6999', 'Ingående moms, blandad verksamhet 70 LÖNER TILL KOLLEKTIVANSTÄLLDA', 'Ingående moms, blandad verksamhet'), + -- Class 7 + ('7090', 'Förändring av semesterlöneskuld 72 LÖNER TILL TJÄNSTEMÄN OCH FÖRETAGSLEDARE', 'Förändring av semesterlöneskuld'), + ('7292', 'Förändring av semesterlöneskuld till företagsledare 73 KOSTNADSERSÄTTNINGAR OCH FÖRMÅNER', 'Förändring av semesterlöneskuld till företagsledare'), + ('7392', 'Kostnad för förmån av hushållsnära tjänster 74 PENSIONSKOSTNADER', 'Kostnad för förmån av hushållsnära tjänster'), + ('7490', 'Övriga pensionskostnader 75 SOCIALA OCH ANDRA AVGIFTER ENLIGT LAG OCH AVTAL', 'Övriga pensionskostnader'), + ('7590', 'Övriga sociala och andra avgifter enligt lag och avtal 76 ÖVRIGA PERSONALKOSTNADER', 'Övriga sociala och andra avgifter enligt lag och avtal'), + ('7699', 'Övriga personalkostnader 77 NEDSKRIVNINGAR OCH ÅTERFÖRING AV NEDSKRIVNINGAR', 'Övriga personalkostnader'), + ('7790', 'Återföring av nedskrivningar av vissa omsättningstillgångar 78 AVSKRIVNINGAR ENLIGT PLAN', 'Återföring av nedskrivningar av vissa omsättningstillgångar'), + ('7840', 'Avskrivningar på förbättringsutgifter på annans fastighet 79 ÖVRIGA RÖRELSEKOSTNADER', 'Avskrivningar på förbättringsutgifter på annans fastighet'), + ('7990', 'Övriga rörelsekostnader 80 RESULTAT FRÅN ANDELAR I KONCERNFÖRETAG', 'Övriga rörelsekostnader'), + -- Class 8 + ('8087', 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag 81 RESULTAT FRÅN ANDELAR I INTRESSEFÖRETAG OCH GEMENSAMT STYRDA FÖRETAG SAMT ÖVRIGA FÖRETAG SOM DET FINNS ETT ÄGARINTRESSE I', 'Återföringar av nedskrivningar av långfristiga fordringar hos dotterföretag'), + ('8187', 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i 82 RESULTAT FRÅN ÖVRIGA VÄRDEPAPPER OCH LÅNGFRISTIGA FORDRINGAR (ANLÄGGNINGSTILLGÅNGAR)', 'Återföringar av nedskrivningar av långfristiga fordringar hos övriga företag som det finns ett ägarintresse i'), + ('8295', 'Orealiserade värdeförändringar på derivatinstrument 83 ÖVRIGA RÄNTEINTÄKTER OCH LIKNANDE RESULTATPOSTER', 'Orealiserade värdeförändringar på derivatinstrument'), + ('8390', 'Övriga finansiella intäkter 84 RÄNTEKOSTNADER OCH LIKNANDE RESULTATPOSTER', 'Övriga finansiella intäkter'), + ('8491', 'Erhållet ackord på skulder till kreditinstitut m.m. 88 BOKSLUTSDISPOSITIONER', 'Erhållet ackord på skulder till kreditinstitut m.m.'), + ('8899', 'Övriga bokslutsdispositioner 89 SKATTER OCH ÅRETS RESULTAT', 'Övriga bokslutsdispositioner') +) +update public.chart_of_accounts coa + set account_name = f.fixed_name, + description = case when coa.description = f.corrupted_name then f.fixed_name else coa.description end, + updated_at = now() + from fixes f + where coa.account_number = f.account_number + and coa.account_name = f.corrupted_name; + +notify pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index b6dcbd12..d844e352 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1323,6 +1323,8 @@ export type PendingOperationType = | 'uncategorize_transaction' // Document inbox: pin doc to bank transaction | 'attach_document_to_transaction' + // Manual transaction ingestion (uncategorized row, reversible by delete) + | 'create_transaction' // Stream 1 Phase 1: supplier invoice lifecycle | 'approve_supplier_invoice' | 'credit_supplier_invoice'