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'