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
@@ -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 },
)