* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1134 lines
46 KiB
TypeScript
1134 lines
46 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
|
|
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 { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
|
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
|
|
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
|
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 { guardCounterLegs } from '@/lib/cash-accounts/service'
|
|
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse, errorResponseFromCode, getStructuredError } 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
|
|
|
|
// Pre-FY guard (issue #1825): a date before the company's first fiscal
|
|
// period must NEVER mint a calendar-year rakenskapsar. Depending on overlap
|
|
// with the real first period, the upsert below would either bounce off the
|
|
// no_overlapping_fiscal_periods exclusion constraint (log noise) or silently
|
|
// create a pre-registration year (legally wrong). Return true and let the
|
|
// pre-FY clamp in createTransactionJournalEntry book the event on the first
|
|
// fiscal year's first day. Dates AFTER the latest period (next-year
|
|
// auto-creation) pass through unchanged.
|
|
const earliestStart = await getEarliestFiscalPeriodStart(supabase, companyId)
|
|
if (earliestStart && date < earliestStart) 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, is_ignored: false })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
|
|
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 = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
|
|
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,
|
|
transaction.currency,
|
|
)
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Issue #1643 problem 4: a learned template or transfer proposal must never
|
|
// book the COUNTER leg onto an orphaned cash-account ledger, or onto a twin
|
|
// ledger of the transaction's own bank account. Confirming such a proposal
|
|
// silently drops revenue/expense from the P&L onto a junk balance-sheet
|
|
// account. A twin that is merely the stale BANK leg of a learned template
|
|
// is rewritten to the settlement account instead (see guardCounterLegs).
|
|
{
|
|
const guarded = await guardCounterLegs(
|
|
supabase,
|
|
companyId!,
|
|
mappingResult,
|
|
settlementAccount,
|
|
transaction.cash_account_id,
|
|
)
|
|
if (guarded.refusedLedger) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT', txLog, {
|
|
requestId,
|
|
details: { accountNumber: guarded.refusedLedger },
|
|
})
|
|
}
|
|
mappingResult = guarded.mappingResult
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Issue #1661: a private marking books eget uttag/insättning, so a locked
|
|
// period refuses it like any verifikat. Pre-check only for private rows:
|
|
// the trigger path below would answer with a bare locked-period cause,
|
|
// while the row the user wants to clear (a duplicate, a PSD2 ghost) is
|
|
// usually no affärshändelse at all and should be ignored instead. The
|
|
// response carries suggested_action: 'ignore' for the one-click toast.
|
|
if (!is_business) {
|
|
const privateLock = await checkPeriodLock(supabase, companyId, transaction.date)
|
|
if (privateLock.locked) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED', txLog, {
|
|
requestId,
|
|
details: {
|
|
transaction_date: transaction.date,
|
|
reason: privateLock.reason,
|
|
fiscal_period_id: privateLock.fiscal_period_id,
|
|
suggested_action: 'ignore',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
let journalEntryCreated = false
|
|
let journalEntryId: 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)
|
|
}
|
|
// Fail closed (issue #1947): the verifikat IS the booking. Writing
|
|
// is_business/category without one used to drop the row out of the
|
|
// canonical worklist predicate in lib/worklist/types.ts (is_business IS
|
|
// NULL) while it was still unbooked, so it vanished from "Att bokföra"
|
|
// and the nav badge with no reminder to finish it. Nothing is persisted
|
|
// when the entry fails: the row stays uncategorized and visible. Both
|
|
// message locales come from getErrorMessage (sv/en from the same error,
|
|
// per the errorResponseFromCode contract: provide both or neither); the
|
|
// raw message is already logged above and must never reach the user
|
|
// verbatim (issue #337).
|
|
const structured = getStructuredError(err)
|
|
return errorResponseFromCode('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED', txLog, {
|
|
requestId,
|
|
messageSv: getErrorMessage(err, { context: 'transaction' }),
|
|
messageEn: getErrorMessage(err, { context: 'transaction', locale: 'en' }),
|
|
details: { cause: structured.code },
|
|
})
|
|
}
|
|
|
|
// createTransactionJournalEntry returns null (no throw) when
|
|
// findFiscalPeriod sees no OPEN period covering the date and the pre-FY
|
|
// clamp does not apply: either no fiscal period exists there at all, or
|
|
// the covering period exists but is closed (is_closed = true; findFiscalPeriod
|
|
// filters is_closed = false). Same fail-closed rule either way: refuse
|
|
// rather than mark the row categorized-but-unbooked. checkPeriodLock tells
|
|
// the two apart so a closed year answers PERIOD_LOCKED (reason
|
|
// period_is_closed) instead of claiming the räkenskapsår does not exist.
|
|
if (!journalEntryId) {
|
|
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
|
|
if (periodLock.locked) {
|
|
return errorResponseFromCode(
|
|
is_business ? 'PERIOD_LOCKED' : 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED',
|
|
txLog,
|
|
{
|
|
requestId,
|
|
details: {
|
|
transaction_date: transaction.date,
|
|
reason: periodLock.reason,
|
|
fiscal_period_id: periodLock.fiscal_period_id,
|
|
...(is_business ? {} : { suggested_action: 'ignore' }),
|
|
},
|
|
},
|
|
)
|
|
}
|
|
return errorResponseFromCode('NO_OPEN_PERIOD_FOR_DATE', txLog, {
|
|
requestId,
|
|
details: { transaction_date: transaction.date, reason: 'no_fiscal_period' },
|
|
})
|
|
}
|
|
|
|
// Learning writes (mapping rule, counterparty template) run only after a
|
|
// posted verifikat, so they never learn from a booking that did not happen.
|
|
// 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,
|
|
is_ignored: false,
|
|
journal_entry_id: journalEntryId,
|
|
})
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.is('journal_entry_id', null)
|
|
.select('*')
|
|
|
|
if (updateError) {
|
|
txLog.error('failed to update transaction', updateError)
|
|
if (journalEntryId) {
|
|
await reverseOrphanedJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
journalEntryId,
|
|
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
|
)
|
|
}
|
|
return errorResponse(updateError, txLog, { requestId })
|
|
}
|
|
|
|
if (!updateResult || updateResult.length === 0) {
|
|
// CAS guard: another request set journal_entry_id between our read and
|
|
// write. If this request posted an orphan, compensate through the
|
|
// bookkeeping engine with a storno entry.
|
|
if (journalEntryId) {
|
|
await reverseOrphanedJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
journalEntryId,
|
|
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
|
)
|
|
}
|
|
|
|
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
|
|
}
|
|
|
|
const updatedTransaction = updateResult[0] as Transaction
|
|
|
|
// 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: updatedTransaction,
|
|
account: mappingResult.debit_account,
|
|
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
|
|
// journal_entry_error is always null here: a failed verifikat now returns
|
|
// a typed 409 above (issue #1947). The field stays for client compatibility.
|
|
return NextResponse.json({
|
|
success: true,
|
|
journal_entry_created: journalEntryCreated,
|
|
journal_entry_id: journalEntryId,
|
|
journal_entry_error: null,
|
|
document_link_warning: documentLinkWarning,
|
|
category: finalCategory,
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|