Logs/improved logging (#398)

* feat(mcp): add create_transactions tool with /pending approval gate

New MCP tool gnubok_create_transactions stages 1–10 transactions per call
as pending_operations of type create_transaction (risk: medium). Each item
becomes its own card on /pending; on confirm, the executor inserts the row
into transactions with import_source='mcp' so MCP-staged ingestion is
distinguishable from PSD2 sync. Designed for skill workflows that pull
external data (e.g., Airtable) and want the user to gate the writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bas): strip concatenated group headers from corrupted account names

A chart-data import bug had glued the next group's header onto the last
account in each preceding group across all eight bas-data class files
(e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27
PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names
surface in transaction dropdowns, ledgers, SIE exports and årsredovisning,
and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet
(6999) accounts specifically.

- Cleans 69 account_name and 64 description fields across class-1..8 files
- Adds a regression test asserting no name contains a concatenated header
- Ships an idempotent safety-net migration that updates already-seeded
  chart_of_accounts rows, gated on the corrupted string so user
  customizations are preserved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(errors): add structured error codes and handling for various operations

- Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application.
- Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors.
- Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints.
- Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations.
- Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping.
- Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry.

* Refactor supplier API routes to use context-based logging and error handling

- Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`.
- Enhanced error handling to provide structured error responses for supplier creation and listing.
- Updated logging to include request IDs for better traceability.
- Introduced new error codes for supplier-related operations.
- Refactored tax deadlines cron job to utilize context and improved error handling.
- Updated ESLint configuration to enforce logging practices across API and lib directories.
- Enhanced arcim migration extension with structured error handling and logging.
- Added classification for provider errors to improve user-facing error messages.
- Introduced request ID in extension context for better log correlation.

* fix(route-context): update DynamicParams type for improved type safety in route handlers

* feat(transactions): add 'create_transaction' operation to PendingOperationType

* fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function

* fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-06 11:12:02 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 94f15b9c6c
commit 5725c25bf1
102 changed files with 7887 additions and 5245 deletions
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+5 -4
View File
@@ -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')
+1 -1
View File
@@ -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)))
+32 -16
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+27
View File
@@ -26,6 +26,7 @@ const operationLabels: Record<string, { label: string; icon: typeof ArrowLeftRig
categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' },
create_customer: { label: 'Ny kund', icon: Users, variant: 'secondary' },
create_invoice: { label: 'Ny faktura', icon: Receipt, variant: 'outline' },
create_transaction: { label: 'Ny transaktion', icon: ArrowLeftRight, variant: 'secondary' },
mark_invoice_paid: { label: 'Betald faktura', icon: Receipt, variant: 'default' },
send_invoice: { label: 'Skicka faktura', icon: Receipt, variant: 'outline' },
mark_invoice_sent: { label: 'Markera skickad', icon: Receipt, variant: 'outline' },
@@ -138,6 +139,30 @@ function InvoicePreview({ data }: { data: Record<string, unknown> }) {
)
}
function CreateTransactionPreview({ data }: { data: Record<string, unknown> }) {
const amount = data.amount as number
const currency = (data.currency as string) || 'SEK'
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Datum</span>
<span className="font-mono">{String(data.date ?? '')}</span>
<span className="text-muted-foreground">Beskrivning</span>
<span className="truncate">{String(data.description ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{formatCurrency(amount, currency)}
</span>
{data.external_id ? (
<>
<span className="text-muted-foreground">Extern referens</span>
<span className="font-mono text-xs truncate">{String(data.external_id)}</span>
</>
) : null}
</div>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data).filter(([, v]) => v != null && v !== '')
return (
@@ -162,6 +187,8 @@ function OperationPreview({ op }: { op: PendingOperation }) {
return <CustomerPreview data={op.preview_data} />
case 'create_invoice':
return <InvoicePreview data={op.preview_data} />
case 'create_transaction':
return <CreateTransactionPreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
@@ -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 {
+3 -3
View File
@@ -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')
+4 -4
View File
@@ -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))
@@ -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 },
)
@@ -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 },
)
@@ -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 })
},
)
@@ -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 },
)
+125 -161
View File
@@ -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<string, unknown> = {}
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<string, unknown> = {}
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 },
)
+98 -98
View File
@@ -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 },
)
+22 -35
View File
@@ -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,
})
})
+43 -49
View File
@@ -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 },
)
+79 -88
View File
@@ -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 })
},
)
+65 -77
View File
@@ -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,
})
})
+21 -32
View File
@@ -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 })
})
@@ -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,
})
}
})
@@ -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)
}
}
+107 -17
View File
@@ -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<Response> {
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<Response> {
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
@@ -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 })
}
}
})
+10 -20
View File
@@ -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 })
}
}
})
+121 -112
View File
@@ -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' },
})
}
},
)
+80 -83
View File
@@ -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 }
)
}
}
},
)
@@ -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 () => {
+195 -220
View File
@@ -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 },
)
+43 -50
View File
@@ -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' },
})
}
},
)
+22 -33
View File
@@ -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 },
)
+180 -208
View File
@@ -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<string, string>()
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<string, string>()
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 },
)
+109 -139
View File
@@ -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 }
)
}
}
},
)
@@ -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()
})
+171 -195
View File
@@ -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 },
)
@@ -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')
})
})
+123 -139
View File
@@ -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<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).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 },
)
+8 -7
View File
@@ -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')
})
})
+34 -42
View File
@@ -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
+243 -254
View File
@@ -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<ReturnType<typeof createClient>>,
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({
+39 -40
View File
@@ -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' },
})
}
},
)
+25 -23
View File
@@ -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' },
})
}
},
)
+39 -41
View File
@@ -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' },
})
}
},
)
+54 -64
View File
@@ -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
}
+43 -55
View File
@@ -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' },
})
}
},
)
+43 -45
View File
@@ -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' },
})
}
},
)
+83 -105
View File
@@ -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' },
})
}
},
)
+110 -113
View File
@@ -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<string, unknown>) => ({
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<string, unknown> = {
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<string, unknown>) => ({
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<string, unknown> = {
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 },
)
+30 -29
View File
@@ -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 },
)
+89 -77
View File
@@ -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 },
)
+21 -29
View File
@@ -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 })
})
+86 -89
View File
@@ -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 },
)
@@ -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 () => {
+48 -55
View File
@@ -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 },
)
+152 -167
View File
@@ -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 },
)
@@ -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 () => {
+163 -178
View File
@@ -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 },
)
@@ -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')
})
})
+254 -275
View File
@@ -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<string, string> = {
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 },
)
+130 -142
View File
@@ -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 },
)
+73 -72
View File
@@ -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 },
)
+20 -32
View File
@@ -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,
})
})
@@ -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()
})
+349 -364
View File
@@ -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<ReturnType<typeof createClient>>,
supabase: SupabaseClient,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
fiscalYearStartMonth: number,
log: Logger,
): Promise<boolean> {
// 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 },
)
@@ -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 () => {
+220 -290
View File
@@ -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 },
)
@@ -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 },
)
+21 -2
View File
@@ -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/**",
+90 -74
View File
@@ -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' },
})
}
},
},
@@ -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<string, unknown>
expect(schema.type).toBe('object')
expect((schema.properties as Record<string, unknown>).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/)
})
})
+109
View File
@@ -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<Record<string, unknown>> | 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.',
+85
View File
@@ -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])
})
})
+30
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { NextResponse } from 'next/server'
import type { Logger } from '@/lib/logger'
export interface ValidationSuccess<T> {
success: true
@@ -13,6 +14,28 @@ export interface ValidationFailure {
export type ValidationResult<T> = ValidationSuccess<T> | 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<T> = ValidationSuccess<T> | ValidationFailure
export async function validateBody<T>(
request: Request,
schema: z.ZodType<T>,
options?: ValidationOptions,
): Promise<ValidationResult<T>> {
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<T>(
code: issue.code,
}))
logIssues(options, 'body', errors)
return {
success: false,
response: NextResponse.json(
@@ -84,6 +111,7 @@ export async function validateBody<T>(
export function validateQuery<T>(
request: Request,
schema: z.ZodType<T>,
options?: ValidationOptions,
): ValidationResult<T> {
const url = new URL(request.url)
const raw = Object.fromEntries(url.searchParams.entries())
@@ -97,6 +125,8 @@ export function validateQuery<T>(
code: issue.code,
}))
logIssues(options, 'query', errors)
return {
success: false,
response: NextResponse.json(
+129
View File
@@ -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<T>(
label: string,
items: T[],
iteratee: (item: T, itemCtx: CronItemContext) => Promise<void>,
): Promise<CronForEachResult>
}
type CronHandler = (request: Request, ctx: CronContext) => Promise<NextResponse | Response>
function generateRequestId(prefix: 'cron' | 'cron_item' = 'cron'): string {
return `${prefix}_${crypto.randomUUID()}`
}
export function withCronContext(
operation: string,
handler: CronHandler,
): (request: Request) => Promise<Response> {
return async function wrapped(request: Request): Promise<Response> {
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 })
}
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Single wrapper that gives every API route the same shape:
*
* - generates a request id (`req_<uuid>`) 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<Record<string, string | string[]>> } | { params: Promise<{}> }
type RouteHandler<P extends DynamicParams = { params: Promise<Record<string, never>> }> = (
request: Request,
ctx: RouteContext,
params: P,
) => Promise<NextResponse | Response>
function generateRequestId(): string {
// crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js.
return `req_${crypto.randomUUID()}`
}
export function withRouteContext<P extends DynamicParams = { params: Promise<Record<string, never>> }>(
operation: string,
handler: RouteHandler<P>,
options: RouteContextOptions = {},
): (request: Request, params: P) => Promise<Response> {
const { requireWrite = false } = options
return async function wrapped(request: Request, params: P): Promise<Response> {
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 })
}
}
}
+1
View File
@@ -49,6 +49,7 @@ export const SCOPE_GROUPS = [
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// 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',
@@ -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) {
+19 -19
View File
@@ -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,
},
@@ -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,
},
+12 -12
View File
@@ -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,
},
+18 -18
View File
@@ -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,
},
@@ -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,
},
@@ -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,
},
+17 -17
View File
@@ -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,
},
+12 -12
View File
@@ -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,
},
@@ -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()
})
@@ -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<ErrorEnvelope> {
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',
})
})
})
+8
View File
@@ -231,6 +231,14 @@ export function getErrorMessage(
if (typeof error === 'object' && error !== null) {
const obj = error as Record<string, unknown>
// 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 {
+263 -61
View File
@@ -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<string, unknown>
resource?: string
}
export type { StructuredErrorRemediation }
export interface StructuredError {
code: string
@@ -41,58 +57,6 @@ interface StructuredErrorOptions {
toolName?: string
}
const ERROR_CODE_REMEDIATION: Record<string, StructuredErrorRemediation> = {
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<string, unknown>,
extra: unknown,
): Record<string, unknown> {
if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
return { ...base, ...(extra as Record<string, unknown>) }
}
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)
}
File diff suppressed because it is too large Load Diff
+21 -17
View File
@@ -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> | 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<string, Set<AnyHandler>>()
/**
* Subscribe to an event type.
* Returns an unsubscribe function.
*/
on<T extends CoreEventType>(
eventType: T,
handler: EventHandler<T>
@@ -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<void> {
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<string, unknown>
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()
}
+17 -5
View File
@@ -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<string, unknown>): 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<string, unknown> = { 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(),
}
}
+6
View File
@@ -168,6 +168,12 @@ export interface ExtensionContext {
userId: string
companyId: string
extensionId: string
/**
* Stable id for the inbound HTTP request — `req_<uuid>`.
* 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<void>
settings: ExtensionSettings
+114
View File
@@ -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<NormalizedError> {
// 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<string, unknown>
const errObj = obj.error
if (errObj && typeof errObj === 'object') {
const e = errObj as Partial<ErrorEnvelope['error']>
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,
})
}
}
+207 -11
View File
@@ -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<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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<string, unknown> {
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<string, unknown> = { ...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<string, unknown>)
} else if (arg !== undefined) {
details.push(redact(arg))
}
}
const record: LogRecord = {
level,
module,
msg: redactString(message),
ts: new Date().toISOString(),
...(redact(ctx) as Record<string, unknown>),
}
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 })
},
}
}
@@ -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', () => {
+49
View File
@@ -306,6 +306,52 @@ async function commitCreateCustomer(
return { data: { customer_id: data.id } }
}
async function commitCreateTransaction(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
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
+1
View File
@@ -26,6 +26,7 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
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
+208
View File
@@ -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/<provider>` logger is created. */
log?: Logger
/** Extra context merged into the log line. */
context?: Record<string, unknown>
}
/**
* 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<T>(
options: ProviderCallOptions,
call: () => Promise<T>,
): Promise<T> {
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
}

Some files were not shown because too many files have changed in this diff Show More