Files
accounted/app/api/transactions/route.ts
T
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00

167 lines
7.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { fetchJunctionLinkedTxIds, scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateTransactionSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const MAX_ROWS = 500
// withRouteContext enforces auth (MFA on hosted) and resolves companyId; the
// previous hand-rolled session lookup in this handler skipped the MFA gate.
export const GET = withRouteContext('transaction.list', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const unmatched = searchParams.get('unmatched') === 'true'
const reconciled = searchParams.get('reconciled') === 'true'
const currency = searchParams.get('currency') || undefined
// currency is interpolated into the PostgREST .or() filter below, so reject
// anything that isn't a 3-letter ISO code. RLS still scopes results to the
// company, but an unsanitized value could otherwise malform or widen the
// filter (PostgREST filter injection).
if (currency && !/^[A-Z]{3}$/.test(currency)) {
return NextResponse.json({ error: 'Ogiltig valutakod' }, { status: 400 })
}
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
// When set, return only ignored rows: used by the reconciliation view to
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
// continues to exclude ignored rows from unmatched results.
const onlyIgnored = searchParams.get('only_ignored') === 'true'
// account_number selects which cash account to scope to. We resolve it to a
// cash_accounts.id (ledger_account is unique per company) and scope
// transactions by that id, falling back to currency for legacy rows whose
// cash_account_id hasn't been backfilled yet. This is what stops two
// same-currency accounts from showing each other's transactions.
const accountNumberParam = searchParams.get('account_number') || undefined
let derivedCurrency = currency
let cashAccountId: string | undefined
// Only the primary account claims unassigned (NULL cash_account_id) rows, so
// a secondary same-currency account's lists match its status card instead of
// pooling the primary's unassigned rows. See scopeTransactionsToAccount.
let includeUnassigned = true
if (accountNumberParam) {
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumberParam)
.maybeSingle()
if (cashAccount) {
cashAccountId = cashAccount.id as string
includeUnassigned = Boolean(cashAccount.is_primary)
if (!derivedCurrency && cashAccount.currency) derivedCurrency = cashAccount.currency as string
}
}
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
.eq('company_id', companyId)
// unmatched and reconciled are mutually exclusive: unmatched wins if both set
if (unmatched) {
query = query.is('journal_entry_id', null)
// Hide rows the user has explicitly suppressed from the reconciliation
// view. Other callers (e.g. BookDirectlyDialog) also benefit: once
// ignored, the row stops surfacing in the "to book" funnel everywhere.
if (!onlyIgnored) query = query.eq('is_ignored', false)
} else if (reconciled) {
query = query.not('journal_entry_id', 'is', null)
}
if (onlyIgnored) query = query.eq('is_ignored', true)
// Scope to the selected cash account. With a resolved id, match that account
// OR legacy NULL rows of the same currency (so nothing disappears mid-
// backfill). With only a currency (no account), filter by currency. With
// neither (e.g. the company-wide only_ignored recovery list), no scope.
// Shares one implementation with the reconciliation lib so the filter shape
// can't drift between the status card and these lists.
if (cashAccountId || derivedCurrency) {
query = scopeTransactionsToAccount(query, cashAccountId, derivedCurrency ?? 'SEK', includeUnassigned)
}
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
// Fetch one extra row so we can tell the caller whether the result was truncated.
query = query.order('date', { ascending: false }).limit(MAX_ROWS + 1)
const { data, error } = await query
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
let rows = data || []
// has_more is decided on what the DB returned, before the junction filter
// below: a page that came back full means more rows exist past it whether
// or not some of this page's rows turn out to be booked.
const hasMore = rows.length > MAX_ROWS
// journal_entry_id IS NULL is only the first of the three "booked" anchors
// (lib/transactions/is-booked.ts): a row bulk-booked into a
// samlingsverifikat or split over several verifikat (1:N, #1553) is
// anchored through transaction_voucher_links alone and must leave
// "Att bokföra" all the same.
if (unmatched && rows.length > 0) {
const junctionLinked = await fetchJunctionLinkedTxIds(
supabase,
companyId,
rows.map((row) => row.id as string),
)
if (junctionLinked.size > 0) rows = rows.filter((row) => !junctionLinked.has(row.id as string))
}
const truncated = rows.length > MAX_ROWS ? rows.slice(0, MAX_ROWS) : rows
return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS })
})
// Manual bank-transaction creation. This is the server-side boundary the form
// now goes through (it used to insert straight into Supabase from the browser).
// withRouteContext enforces auth/MFA + resolves companyId; validateBody runs
// the shared CreateTransactionSchema so the date rule etc. are validated
// server-side, not just client-side.
export const POST = withRouteContext(
'transaction.create',
async (request, { supabase, companyId, user, log }) => {
// Pass the request-scoped logger so a rejected payload (e.g. a malformed
// date) is recorded server-side: that's where anomaly detection belongs,
// not in the render-path formatter.
const validation = await validateBody(request, CreateTransactionSchema, {
log,
operation: 'transaction.create',
})
if (!validation.success) return validation.response
const { date, description, amount, currency, category, notes } = validation.data
const { data: transaction, error } = await supabase
.from('transactions')
.insert({
company_id: companyId,
user_id: user.id,
date,
description,
amount,
currency,
category: category ?? 'uncategorized',
is_business: null,
notes: notes ?? '',
})
.select()
.single()
if (error) {
// A DB-level rejection here (e.g. the transactions_date_sane_range CHECK)
// is invalid input, not a server fault: surface it as 400 with the PG
// code so the client maps it to a friendly message.
return NextResponse.json(
{ error: getUserErrorMessage(error), code: error.code, type: 'database_error' },
{ status: 400 },
)
}
return NextResponse.json({ data: transaction }, { status: 201 })
},
{ requireWrite: true },
)