* feat(categorize): dimension bags end-to-end + runtime template learning The categorize path could not tag: categorize-core accepted a dimensions bag but no route or UI ever passed one, and runtime template learning dropped the bag entirely (only SIE import produced dimension-carrying patterns). - CategorizeTransactionSchema gains dimensions; the dashboard route, v1 single and v1 batch-categorize apply it to the mapping result's business lines (explicit bag wins over a learned counterparty bag). - categorization_templates.default_dimensions (migration 20260728091000) records the bag of the latest tagged booking; latest-explicit-wins, an untagged booking never erases it. Applied on the legacy single-line template path and the mirrored-refund path; multi-line SIE patterns keep their authoritative per-entry bags. - QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled gate, same as BulkBookDialog), prefilled from the counterparty suggestion's learned bag; hidden for multi-line patterns whose per-line bags would ignore an edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Renumber migration above 20260728120000 (out-of-order vs prod after #1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1023 lines
41 KiB
TypeScript
1023 lines
41 KiB
TypeScript
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 { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
|
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
|
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
|
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
|
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import {
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
DUPLICATE_DATE_WINDOW_DAYS,
|
|
escapeLikePattern,
|
|
normalizeOcrReference,
|
|
} from '@/lib/invoices/duplicate-payment-guard'
|
|
import {
|
|
invoiceAmountSek,
|
|
magnitudesWithinTolerance,
|
|
normalizeCurrencyCode,
|
|
planAmountSweeps,
|
|
type ComparableAmount,
|
|
} from '@/lib/invoices/duplicate-guard-currency'
|
|
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
|
|
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
|
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
|
|
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'
|
|
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* Ensure a fiscal period exists for the given date, create one if needed.
|
|
*/
|
|
async function ensureFiscalPeriod(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
date: string,
|
|
fiscalYearStartMonth: number,
|
|
log: Logger,
|
|
): Promise<boolean> {
|
|
const { data: existing } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', date)
|
|
.gte('period_end', date)
|
|
.eq('is_closed', false)
|
|
.limit(1)
|
|
|
|
if (existing && existing.length > 0) return true
|
|
|
|
const txDate = new Date(date)
|
|
const txMonth = txDate.getMonth() + 1
|
|
const txYear = txDate.getFullYear()
|
|
|
|
let periodStartYear: number
|
|
if (fiscalYearStartMonth === 1) {
|
|
periodStartYear = txYear
|
|
} else if (txMonth >= fiscalYearStartMonth) {
|
|
periodStartYear = txYear
|
|
} else {
|
|
periodStartYear = txYear - 1
|
|
}
|
|
|
|
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
|
const periodStart = `${periodStartYear}-${startMonth}-01`
|
|
|
|
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
|
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
|
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
|
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
|
|
|
const periodName = fiscalYearStartMonth === 1
|
|
? `Räkenskapsår ${periodStartYear}`
|
|
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
|
|
|
const { error } = await supabase
|
|
.from('fiscal_periods')
|
|
.upsert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: periodName,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
}, {
|
|
onConflict: 'company_id,period_start,period_end',
|
|
})
|
|
|
|
if (error) {
|
|
log.error('failed to create fiscal period', error)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
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 validation = await validateBody(request, CategorizeTransactionSchema, {
|
|
log,
|
|
operation: 'transaction.categorize',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
const { is_business, category } = body
|
|
|
|
const { data: transaction, error: fetchError } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// Booking-time duplicate guard: this transaction is about to become a NEW
|
|
// verifikat. If another transaction on the same date+amount+account is
|
|
// already booked, booking this one double-counts one real affärshändelse
|
|
// (felaktig bokföring per BFL). Warn; the user confirms with force=true
|
|
// bound to the reviewed sibling. Mirrors the match-invoice soft-duplicate
|
|
// guard. Runs before any categorization work so the user resolves it first.
|
|
try {
|
|
const candidate = await detectBookingDuplicate(supabase, companyId, {
|
|
id,
|
|
date: transaction.date,
|
|
amount: transaction.amount,
|
|
// `amount` is denominated in `currency`; the ledger legs the guard
|
|
// compares it against are always SEK. Selected above via select('*').
|
|
currency: transaction.currency ?? null,
|
|
amount_sek: transaction.amount_sek ?? null,
|
|
exchange_rate: transaction.exchange_rate ?? null,
|
|
cash_account_id: transaction.cash_account_id ?? null,
|
|
})
|
|
if (!body.force) {
|
|
if (candidate) {
|
|
return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', txLog, {
|
|
requestId,
|
|
details: { candidate },
|
|
})
|
|
}
|
|
} else if (
|
|
// force=true is bound to the reviewed candidate. A sibling-transaction
|
|
// candidate carries a transaction_id; a ledger-only voucher candidate
|
|
// does not, so both are bound by journal_entry_id. Re-detect and refuse
|
|
// the bypass unless it still matches, so a guessed id can't wave it away.
|
|
!candidate ||
|
|
!(
|
|
(candidate.journal_entry_id && candidate.journal_entry_id === body.expected_duplicate_journal_entry_id) ||
|
|
(candidate.transaction_id && candidate.transaction_id === body.expected_duplicate_transaction_id)
|
|
)
|
|
) {
|
|
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', txLog, {
|
|
requestId,
|
|
details: {
|
|
expected_duplicate_transaction_id: body.expected_duplicate_transaction_id ?? null,
|
|
expected_duplicate_journal_entry_id: body.expected_duplicate_journal_entry_id ?? null,
|
|
detected_transaction_id: candidate?.transaction_id ?? null,
|
|
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
|
|
},
|
|
})
|
|
} else {
|
|
txLog.warn('booking-time duplicate guard bypassed', {
|
|
reason: 'force=true',
|
|
requestId,
|
|
dismissedTransactionId: candidate.transaction_id,
|
|
})
|
|
// Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): // booking over a DETECTED possible double-booking is a bookkeeping
|
|
// decision that needs a durable record. Best-effort; never blocks the
|
|
// booking.
|
|
try {
|
|
await appendProcessingHistory({
|
|
companyId,
|
|
correlationId: id,
|
|
aggregateType: 'BankTransaction',
|
|
aggregateId: id,
|
|
eventType: 'BankTransactionDuplicateDismissed',
|
|
payload: {
|
|
transaction_id: id,
|
|
dismissed_transaction_id: candidate.transaction_id,
|
|
dismissed_journal_entry_id: candidate.journal_entry_id,
|
|
// Null when the candidate's SEK value could not be established
|
|
// (a rateless foreign sibling); the foreign figures below then
|
|
// carry the durable record instead of a fabricated kr amount.
|
|
amount_ore: candidate.amount != null ? Math.round(candidate.amount * 100) : null,
|
|
dismissed_currency: candidate.currency,
|
|
dismissed_amount_in_currency: candidate.amount_in_currency,
|
|
entry_date: candidate.entry_date,
|
|
// Whether the user dismissed a confirmed same-amount twin or a
|
|
// candidate whose kr figure was never established (BFNAR 2013:2
|
|
// kap 8: the behandlingshistorik has to say which). Parity with
|
|
// the /book route's dismissal record.
|
|
amount_verified: candidate.amount_verified,
|
|
unverified_reason: candidate.unverified_reason,
|
|
},
|
|
actor: { type: 'user', id: user.id },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (logErr) {
|
|
txLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (body.force) {
|
|
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', txLog, {
|
|
requestId,
|
|
details: { detection_failed: true },
|
|
})
|
|
}
|
|
txLog.warn('booking-time duplicate detection failed (continuing)', err as Error)
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
// Book the bank leg against the transaction's ACTUAL settlement account
|
|
// rather than the hardcoded 1930 in the templates. Without this, interest
|
|
// or fees that landed on a savings/EUR account mis-book to 1930 and the
|
|
// real bank line never reconciles. applySettlementAccount only rewrites a
|
|
// 1930 leg and is a no-op when the settlement account is 1930, so legacy
|
|
// rows with no cash_account_id behave exactly as before.
|
|
const settlementAccount = await resolveSettlementAccount(
|
|
supabase,
|
|
companyId!,
|
|
transaction.cash_account_id,
|
|
txLog,
|
|
)
|
|
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
|
|
|
txLog.info('mapping resolved', {
|
|
debit: mappingResult.debit_account,
|
|
credit: mappingResult.credit_account,
|
|
allLinesComplete: mappingResult.all_lines_complete || false,
|
|
vatLineCount: mappingResult.vat_lines.length,
|
|
})
|
|
|
|
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)
|
|
.eq('is_active', true)
|
|
.single()
|
|
|
|
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 = []
|
|
}
|
|
}
|
|
|
|
// Dimensions: an explicitly picked bag tags the business lines of the
|
|
// generated verifikat (bank/VAT legs stay untagged, see
|
|
// buildTransactionEntryLines). It wins over a learned counterparty-
|
|
// template bag; omitted = the learned bag (if any) applies unchanged.
|
|
if (body.dimensions && Object.keys(body.dimensions).length > 0) {
|
|
mappingResult.dimensions = body.dimensions
|
|
}
|
|
|
|
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
|
|
requestId,
|
|
details: {
|
|
debitAccount: mappingResult.debit_account,
|
|
creditAccount: mappingResult.credit_account,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Pre-validate every account the engine will resolve. Templates,
|
|
// counterparty templates, and category defaults can all reference accounts
|
|
// that aren't activated in this company's kontoplan. Without this check,
|
|
// the engine throws AccountsNotInChartError mid-flight and the legacy
|
|
// catch below silently marks the transaction as bokförd with no
|
|
// verifikation. Catching it here means the row stays in "Att bokföra"
|
|
// and the user gets a clear actionable message.
|
|
//
|
|
// Only truly unresolvable accounts block: a standard BAS account that is
|
|
// merely absent from the chart is seeded on demand by the engine, so the
|
|
// user can always book the row without registering accounts first.
|
|
const missingAccounts = await findUnresolvableAccounts(
|
|
supabase,
|
|
companyId,
|
|
collectMappingResultAccounts(mappingResult),
|
|
)
|
|
if (missingAccounts.length > 0) {
|
|
txLog.warn('mapping references inactive/unknown accounts', { missingAccounts })
|
|
return accountsNotInChartResponse(new AccountsNotInChartError(missingAccounts))
|
|
}
|
|
|
|
if (body.confirm_no_match && /^244\d$/.test(mappingResult.debit_account)) {
|
|
txLog.warn('supplier-invoice match suggestion bypassed', {
|
|
reason: 'confirm_no_match=true',
|
|
debitAccount: mappingResult.debit_account,
|
|
creditAccount: mappingResult.credit_account,
|
|
})
|
|
}
|
|
if (body.confirm_no_match && /^151\d$/.test(mappingResult.credit_account)) {
|
|
txLog.warn('customer-invoice match suggestion bypassed', {
|
|
reason: 'confirm_no_match=true',
|
|
debitAccount: mappingResult.debit_account,
|
|
creditAccount: mappingResult.credit_account,
|
|
})
|
|
}
|
|
|
|
// Units for both invoice-suggestion prongs below. `transactions.amount` is
|
|
// denominated in `transactions.currency`, while `remaining_amount` on
|
|
// `supplier_invoices` / `invoices` is denominated in the INVOICE's
|
|
// currency. A plus-minus 2 % band built around a EUR bank row and applied
|
|
// to a kronor `remaining_amount` column is off by the whole exchange rate:
|
|
// it either matches nothing or points the user at an unrelated invoice.
|
|
// `planAmountSweeps` therefore issues one SQL sweep per currency (band and
|
|
// column in the same unit) and `magnitudesWithinTolerance` re-checks every
|
|
// returned row. A SEK transaction yields exactly one sweep with the band it
|
|
// had before, so a SEK-only company runs the identical single query.
|
|
const txReferenceAmount: ComparableAmount = {
|
|
amount: transaction.amount,
|
|
currency: normalizeCurrencyCode(transaction.currency),
|
|
sek: resolveTransactionAmountSek({
|
|
amount: transaction.amount,
|
|
currency: transaction.currency,
|
|
amount_sek: transaction.amount_sek,
|
|
exchange_rate: transaction.exchange_rate,
|
|
}),
|
|
}
|
|
|
|
/** A candidate invoice row as a comparable amount (pro-rates `total_sek`). */
|
|
const invoiceRowAmount = (row: {
|
|
remaining_amount: number | null
|
|
total?: number | null
|
|
currency: string | null
|
|
total_sek?: number | null
|
|
exchange_rate?: number | null
|
|
}): ComparableAmount => {
|
|
const remaining = row.remaining_amount ?? row.total ?? 0
|
|
const currency = normalizeCurrencyCode(row.currency)
|
|
return {
|
|
amount: Number(remaining),
|
|
currency,
|
|
sek: invoiceAmountSek({
|
|
amount: Number(remaining),
|
|
currency,
|
|
total: row.total,
|
|
totalSek: row.total_sek,
|
|
exchangeRate: row.exchange_rate,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Prong B: intercept plain 244x categorization of supplier payments when
|
|
// an open supplier invoice already covers this amount. Categorizing direct
|
|
// to 244x leaves the invoice with status='approved' and lures the user
|
|
// into a duplicate "Markera som betald" later. Credit must be a bank/cash
|
|
// account (1xxx): 244x against a clearing account, equity, etc. isn't a
|
|
// supplier payment and the suggestion would misdirect the user.
|
|
if (
|
|
!body.confirm_no_match &&
|
|
is_business &&
|
|
transaction.amount < 0 &&
|
|
/^244\d$/.test(mappingResult.debit_account) &&
|
|
/^1\d{3}$/.test(mappingResult.credit_account)
|
|
) {
|
|
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
|
txReferenceAmount,
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
)
|
|
if (crossCurrencyUnverifiable) {
|
|
// A foreign bank row with neither amount_sek nor exchange_rate cannot
|
|
// be stated in kronor, so kronor invoices are excluded rather than
|
|
// compared raw. Logged: an unevaluated candidate set is not the same
|
|
// thing as "no open invoice matches".
|
|
txLog.warn('supplier-invoice suggestion: cross-currency candidates not evaluated', {
|
|
reason: 'transaction_missing_sek_value',
|
|
currency: txReferenceAmount.currency,
|
|
})
|
|
}
|
|
|
|
let supplierIds: string[] = []
|
|
if (transaction.merchant_name) {
|
|
const escapedMerchant = escapeLikePattern(transaction.merchant_name)
|
|
const { data: matchedSuppliers } = await supabase
|
|
.from('suppliers')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.ilike('name', `%${escapedMerchant}%`)
|
|
.limit(10)
|
|
supplierIds = (matchedSuppliers || []).map((s) => s.id)
|
|
}
|
|
|
|
if (supplierIds.length > 0) {
|
|
// Restrict candidates to invoices within the date window relative to
|
|
// the bank tx date. Without this, an open invoice from years back can
|
|
// surface as a match and misdirect the user (swedish-compliance bot).
|
|
const txDateMs = new Date(transaction.date).getTime()
|
|
const invoiceDateLow = new Date(txDateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
const invoiceDateHigh = new Date(txDateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
|
|
type SupplierCandidateRow = {
|
|
id: string
|
|
supplier_invoice_number: string | null
|
|
invoice_date: string
|
|
remaining_amount: number | null
|
|
total: number | null
|
|
currency: string | null
|
|
total_sek: number | null
|
|
exchange_rate: number | null
|
|
supplier: { name?: string } | null
|
|
}
|
|
|
|
const sweepResults = await Promise.all(
|
|
sweeps.map((sweep) =>
|
|
supabase
|
|
.from('supplier_invoices')
|
|
.select(
|
|
'id, supplier_invoice_number, invoice_date, remaining_amount, total, currency, total_sek, exchange_rate, supplier:suppliers(name)',
|
|
)
|
|
.eq('company_id', companyId)
|
|
.in('supplier_id', supplierIds)
|
|
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
|
.or(sweep.currencyFilter)
|
|
.gte('remaining_amount', sweep.low)
|
|
.lte('remaining_amount', sweep.high)
|
|
.gte('invoice_date', invoiceDateLow)
|
|
.lte('invoice_date', invoiceDateHigh)
|
|
.order('invoice_date', { ascending: false })
|
|
.limit(5),
|
|
),
|
|
)
|
|
|
|
const byId = new Map<string, SupplierCandidateRow>()
|
|
for (const res of sweepResults) {
|
|
for (const row of (res.data ?? []) as unknown as SupplierCandidateRow[]) {
|
|
if (!byId.has(row.id)) byId.set(row.id, row)
|
|
}
|
|
}
|
|
const openInvoices = Array.from(byId.values())
|
|
.filter((inv) =>
|
|
magnitudesWithinTolerance(
|
|
txReferenceAmount,
|
|
invoiceRowAmount(inv),
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
),
|
|
)
|
|
.sort((a, b) => (a.invoice_date < b.invoice_date ? 1 : a.invoice_date > b.invoice_date ? -1 : 0))
|
|
.slice(0, 5)
|
|
|
|
if (openInvoices.length > 0) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_SI_MATCH', txLog, {
|
|
requestId,
|
|
details: {
|
|
candidates: openInvoices.map((inv) => ({
|
|
supplier_invoice_id: inv.id,
|
|
invoice_number: inv.supplier_invoice_number,
|
|
invoice_date: inv.invoice_date,
|
|
remaining_amount: inv.remaining_amount,
|
|
currency: inv.currency,
|
|
supplier_name: (inv.supplier as { name?: string } | null)?.name ?? null,
|
|
})),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prong B (customer side): intercept plain 151x categorization of an
|
|
// inbound payment when an unpaid customer invoice already covers this
|
|
// amount. Symmetric with the supplier-side intercept above. The debit
|
|
// must be a bank/cash account (^19\d{2}$, BAS class 19): a 1xxx debit
|
|
// outside class 19 isn't a payment receipt and the suggestion would
|
|
// misdirect the user.
|
|
if (
|
|
!body.confirm_no_match &&
|
|
is_business &&
|
|
transaction.amount > 0 &&
|
|
/^19\d{2}$/.test(mappingResult.debit_account) &&
|
|
/^151\d$/.test(mappingResult.credit_account)
|
|
) {
|
|
const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps(
|
|
txReferenceAmount,
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
)
|
|
if (crossCurrencyUnverifiable) {
|
|
txLog.warn('customer-invoice suggestion: cross-currency candidates not evaluated', {
|
|
reason: 'transaction_missing_sek_value',
|
|
currency: txReferenceAmount.currency,
|
|
})
|
|
}
|
|
|
|
// Resolve candidate customer(s) by name. Inbound bank txs are typically
|
|
// described by payer name in EITHER merchant_name OR description, so
|
|
// search both. OCR-direct lookup is below.
|
|
let customerIds: string[] = []
|
|
const searchTerms: string[] = []
|
|
if (transaction.merchant_name) searchTerms.push(transaction.merchant_name)
|
|
if (transaction.description) searchTerms.push(transaction.description)
|
|
const collected = new Set<string>()
|
|
for (const term of searchTerms) {
|
|
const escaped = escapeLikePattern(term)
|
|
const { data: matched } = await supabase
|
|
.from('customers')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.ilike('name', `%${escaped}%`)
|
|
.limit(10)
|
|
for (const c of matched ?? []) collected.add(c.id)
|
|
}
|
|
customerIds = Array.from(collected)
|
|
|
|
// Date window anchored on `due_date`, NOT `invoice_date`. Customer
|
|
// payments arrive close to (or after) the due date; for an invoice
|
|
// with 60-90 day terms, anchoring on invoice_date would push the
|
|
// expected payment outside a ±60-day window and the guard would miss
|
|
// genuine matches. due_date is the better proxy for "around when the
|
|
// payment is expected."
|
|
const txDateMs = new Date(transaction.date).getTime()
|
|
const dueDateLow = new Date(txDateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
const dueDateHigh = new Date(txDateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
|
|
type CandidateRow = {
|
|
id: string
|
|
invoice_number: string | null
|
|
invoice_date: string
|
|
due_date: string | null
|
|
remaining_amount: number | null
|
|
total: number
|
|
currency: string | null
|
|
total_sek: number | null
|
|
exchange_rate: number | null
|
|
customer: { name?: string } | null
|
|
}
|
|
const CANDIDATE_COLUMNS =
|
|
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, total_sek, exchange_rate, customer:customers(name)'
|
|
const openInvoiceCandidates: CandidateRow[] = []
|
|
/** Same-unit re-check: drops any row the SQL sweep let through. */
|
|
const comparable = (row: CandidateRow) =>
|
|
magnitudesWithinTolerance(
|
|
txReferenceAmount,
|
|
invoiceRowAmount(row),
|
|
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
|
)
|
|
|
|
if (customerIds.length > 0) {
|
|
const sweepResults = await Promise.all(
|
|
sweeps.map((sweep) =>
|
|
supabase
|
|
.from('invoices')
|
|
.select(CANDIDATE_COLUMNS)
|
|
.eq('company_id', companyId)
|
|
.in('customer_id', customerIds)
|
|
.in('status', ['sent', 'overdue', 'partially_paid'])
|
|
.or(sweep.currencyFilter)
|
|
.gte('remaining_amount', sweep.low)
|
|
.lte('remaining_amount', sweep.high)
|
|
.gte('due_date', dueDateLow)
|
|
.lte('due_date', dueDateHigh)
|
|
.order('due_date', { ascending: false })
|
|
.limit(5),
|
|
),
|
|
)
|
|
for (const res of sweepResults) {
|
|
for (const row of (res.data ?? []) as unknown as CandidateRow[]) {
|
|
if (!comparable(row)) continue
|
|
if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) {
|
|
openInvoiceCandidates.push(row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// OCR pass: if the bank-tx reference matches an open invoice's
|
|
// invoice_number, surface it regardless of customer-name match. This
|
|
// catches the common case where the bank populated `reference` but
|
|
// neither merchant_name nor description carried the customer name.
|
|
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
|
const normalizedTxRef = normalizeOcrReference(txReference ?? null)
|
|
if (normalizedTxRef) {
|
|
const refSweepResults = await Promise.all(
|
|
sweeps.map((sweep) =>
|
|
supabase
|
|
.from('invoices')
|
|
.select(CANDIDATE_COLUMNS)
|
|
.eq('company_id', companyId)
|
|
.in('status', ['sent', 'overdue', 'partially_paid'])
|
|
.or(sweep.currencyFilter)
|
|
.gte('remaining_amount', sweep.low)
|
|
.lte('remaining_amount', sweep.high)
|
|
.gte('due_date', dueDateLow)
|
|
.lte('due_date', dueDateHigh)
|
|
.order('due_date', { ascending: false })
|
|
.limit(20),
|
|
),
|
|
)
|
|
for (const res of refSweepResults) {
|
|
for (const row of (res.data ?? []) as unknown as CandidateRow[]) {
|
|
if (normalizeOcrReference(row.invoice_number) !== normalizedTxRef) continue
|
|
if (!comparable(row)) continue
|
|
if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) {
|
|
openInvoiceCandidates.unshift(row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (openInvoiceCandidates.length > 0) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_CI_MATCH', txLog, {
|
|
requestId,
|
|
details: {
|
|
candidates: openInvoiceCandidates.slice(0, 5).map((inv) => {
|
|
const reasonOcr =
|
|
normalizedTxRef && normalizeOcrReference(inv.invoice_number) === normalizedTxRef
|
|
return {
|
|
invoice_id: inv.id,
|
|
invoice_number: inv.invoice_number,
|
|
invoice_date: inv.invoice_date,
|
|
remaining_amount: inv.remaining_amount ?? inv.total,
|
|
currency: inv.currency,
|
|
customer_name: inv.customer?.name ?? null,
|
|
match_reason: reasonOcr ? ('ocr_exact' as const) : ('name_amount_fuzzy' as const),
|
|
}
|
|
}),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
// AccountsNotInChartError means an account was deactivated between our
|
|
// pre-validation and the engine call (rare race). Don't fall through to
|
|
// the partial-success path: that would mark the transaction bokförd
|
|
// with no verifikation and leave the user staring at an unclosable
|
|
// dialog. Return a structured 400 so the row stays in "Att bokföra"
|
|
// and the user can re-activate the account and retry.
|
|
if (err instanceof AccountsNotInChartError) {
|
|
return accountsNotInChartResponse(err)
|
|
}
|
|
// All errors map to Swedish via getErrorMessage: the raw message is
|
|
// already logged above and must never reach the user verbatim (issue
|
|
// #337). The categorization is preserved either way so the user can
|
|
// still re-book the verifikation manually.
|
|
journalEntryError = getErrorMessage(err, { context: 'transaction' })
|
|
}
|
|
|
|
// direction_mismatch = a mirrored refund/repayment booking; learning it
|
|
// as a rule would store backwards accounts for the merchant.
|
|
if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) {
|
|
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 {
|
|
// Templates are company-scoped since the multi-tenant refactor: passing
|
|
// user.id here broke learning entirely (FK/RLS reject the write).
|
|
await upsertCounterpartyTemplate(
|
|
supabase, companyId, 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', 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.'
|
|
}
|
|
}
|
|
|
|
if (body.inbox_item_id && journalEntryId) {
|
|
try {
|
|
const { data: inboxItem } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('document_id')
|
|
.eq('id', body.inbox_item_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (inboxItem?.document_id) {
|
|
await supabase
|
|
.from('document_attachments')
|
|
.update({ journal_entry_id: journalEntryId })
|
|
.eq('id', inboxItem.document_id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
|
|
// Reflect the booking back onto the inbox row so it stops appearing as
|
|
// unmatched. Categorizing here puts the underlag on a verifikation,
|
|
// which is the inbox's "booked" state. Without this the inbox keeps
|
|
// offering "Matcha mot transaktion" for an underlag that's already on a
|
|
// posted entry, while the transactions view (which reads the
|
|
// doc↔verifikat link) already shows it as attached. Mirrors the
|
|
// backfill that /attach-document does for the manual paperclip path.
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
matched_transaction_id: id,
|
|
created_journal_entry_id: journalEntryId,
|
|
})
|
|
.eq('id', body.inbox_item_id)
|
|
.eq('company_id', companyId)
|
|
} catch (inboxErr) {
|
|
txLog.warn('failed to sync inbox item after booking (non-critical)', inboxErr as Error)
|
|
}
|
|
}
|
|
|
|
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 through
|
|
// the shared helper (BFNAR 2013:2), which owns the correct
|
|
// voucher_gap_explanations column set and logs failures loudly.
|
|
await cancelOrphanedPaymentEntry(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
journalEntryId,
|
|
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
|
)
|
|
|
|
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
|
|
}
|
|
|
|
// Flag any inbox underlag already matched to this transaction as booked.
|
|
// The block above only fires when the caller passes an explicit
|
|
// inbox_item_id (booking straight from the inbox flow). Booking the same
|
|
// transaction from anywhere else (the /transactions list, quick review)
|
|
// would otherwise leave an attached underlag stuck as "Kopplad" in the
|
|
// inbox forever. Here we resolve it by the link itself (matched_transaction
|
|
// _id) so the inbox reflects the booking regardless of entry point. Mirrors
|
|
// the propagation in lib/pending-operations/commit.ts. Runs post-CAS so we
|
|
// never stamp the inbox with a journal entry that lost the race.
|
|
if (journalEntryId) {
|
|
try {
|
|
const { data: matchedInboxItems } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('id, document_id')
|
|
.eq('company_id', companyId)
|
|
.eq('matched_transaction_id', id)
|
|
.is('created_journal_entry_id', null)
|
|
|
|
for (const inbox of (matchedInboxItems ?? []) as Array<{
|
|
id: string
|
|
document_id: string | null
|
|
}>) {
|
|
if (inbox.document_id) {
|
|
await supabase
|
|
.from('document_attachments')
|
|
.update({ journal_entry_id: journalEntryId })
|
|
.eq('id', inbox.document_id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ created_journal_entry_id: journalEntryId })
|
|
.eq('id', inbox.id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
} catch (inboxErr) {
|
|
txLog.warn('failed to flag matched inbox items after booking (non-critical)', inboxErr as Error)
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
})
|
|
|
|
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({
|
|
success: true,
|
|
journal_entry_created: journalEntryCreated,
|
|
journal_entry_id: journalEntryId,
|
|
journal_entry_error: journalEntryError,
|
|
document_link_warning: documentLinkWarning,
|
|
category: finalCategory,
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|