Files
accounted/lib/bookkeeping/engine.ts
T
Jakob WennbergandClaude Opus 4.7 0076aa85f8 feat: arcim inbox (Resend Inbound) + smart-match extension + commit metadata (#286)
* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker

- SIE import preserves each voucher's source series (B/C/I/V/...), essential
  for Fortnox migrations where series carry semantic meaning (kundfakturor,
  inbetalningar, etc.). Target numbering still goes through next_voucher_number
  per series; source (series, number) is stored in the migration mapping for
  BFNAR 2013:2 audit trail.
- Execute route reads company_settings.default_voucher_series as the fallback
  for vouchers arriving without a series (SIE4I).
- Extract shared FiscalYearSelector component; adopt in /reports and
  /bookkeeping.
- Transaction TemplatePicker now surfaces user-created library templates
  (company + team scope) alongside the static registry, with a helper to
  convert simple library templates into the BookingTemplate shape.
- Exclude 8999 "Årets resultat" from income statement financial section and
  monthly breakdown so year-end closing entries don't cancel the net result.

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

* test: skip Bokio SIE regression when fixtures are absent

/dev_docs is gitignored (contains anonymised customer exports), so the
integration test can't find its input files in CI. Gate the suite on
fixture presence so it still runs locally.

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

* fix: address Greptile review feedback

- convertLibraryToBookingTemplate: default entity_applicability to 'all'
  when the source template has no entity_type, so TemplatePicker doesn't
  silently hide it for companies with a set entity type.
- FiscalYearSelector: fire onReady in the no-company early-return branch
  so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton
  while the company context is still hydrating.

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

* feat: arcim inbox + smart-match extension + commit metadata

Three threads, all gated off in extensions.config.json (invoice-inbox and
inbox-smart-match are not in the enabled list for this PR).

invoice-inbox: Gmail OAuth -> Resend Inbound (v2.0.0)
- Remove gmail-scanner / gmail-helpers
- Add resend-inbound.ts (webhook verify, attachment fetch) and
  inbox-provisioning.ts (per-company @arcim.io address with rotation)
- Replace /gmail/* routes with /inbox/address and admin-only /inbox/rotate
- Workspace UI: card layout + MatchBlock surfacing AI transaction matches
- classify-document: tightened discount/total prompt; cap confidence at
  50% when line items do not reconcile with amount_incl_vat
- Manifest requires RESEND_API_KEY, RESEND_INBOUND_DOMAIN,
  RESEND_INBOUND_WEBHOOK_SECRET

inbox-smart-match (new extension)
- Event-driven AI matching of receipts to bank transactions
- Listens on inbox_item.classified (match now) and transaction.synced
  (retro-match receipts waiting for a transaction)
- Uses service-role client; processing_history append is scoped by
  company_id from the event payload

commit metadata + audit plumbing
- journal_entries gains commit_method and rubric_version columns
- commit_journal_entry RPC accepts both (BFNAR 2013:2 behandlingshistorik)
- processing-history PII detector strips UUID-shaped substrings before
  personnummer pattern matching (UUIDs were triggering false positives)
- New generic inbox_item.classified event

Migrations
- arcim_inbox: company_inboxes table, resend_email_id, email_body_text,
  auto-provision trigger, drops obsolete email_connections
- journal_entry_commit_metadata: new columns + updated RPC
- inbox_attachment_composite: resend_attachment_id + composite unique index
- inbox_smart_match: correlation_id, match_reasoning, expanded match_method
  CHECK, pending-match and correlation indexes

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:50:07 +02:00

553 lines
18 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
} from '@/types'
/**
* Validate that a set of journal entry lines is balanced (debits = credits)
*/
export function validateBalance(lines: CreateJournalEntryLineInput[]): {
valid: boolean
totalDebit: number
totalCredit: number
} {
const totalDebit = lines.reduce((sum, l) => sum + (l.debit_amount || 0), 0)
const totalCredit = lines.reduce((sum, l) => sum + (l.credit_amount || 0), 0)
// Round to avoid floating point issues (2 decimal places for SEK)
const roundedDebit = Math.round(totalDebit * 100) / 100
const roundedCredit = Math.round(totalCredit * 100) / 100
return {
valid: roundedDebit === roundedCredit && roundedDebit > 0,
totalDebit: roundedDebit,
totalCredit: roundedCredit,
}
}
/**
* Get the next voucher number for a company/period/series
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
*/
export async function getNextVoucherNumber(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
series: string = 'A'
): Promise<number> {
const { data, error } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
})
if (error) {
throw new Error(`Failed to get next voucher number: ${error.message}`)
}
return data as number
}
/**
* Resolve account IDs from account numbers for a company
*/
async function resolveAccountIds(
supabase: SupabaseClient,
companyId: string,
lines: CreateJournalEntryLineInput[]
): Promise<Map<string, string>> {
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
const { data: accounts, error } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.in('account_number', accountNumbers)
if (error) {
throw new Error(`Failed to resolve account IDs: ${error.message}`)
}
const map = new Map<string, string>()
for (const account of accounts || []) {
map.set(account.account_number, account.id)
}
return map
}
/**
* Find the fiscal period for a given date
*/
export async function findFiscalPeriod(
supabase: SupabaseClient,
companyId: string,
date: string
): Promise<string | null> {
// Overlapping periods are prevented by a DB exclusion constraint
// (migration 042). limit(1) is kept as a defensive measure.
const { data, error } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
.order('period_start', { ascending: false })
.limit(1)
if (error || !data || data.length === 0) {
return null
}
return data[0].id
}
/**
* Build line insert objects from input lines, resolving account IDs and
* including tax_code, cost_center, project dimensions
*/
function buildLineInserts(
entryId: string,
lines: CreateJournalEntryLineInput[],
accountIdMap: Map<string, string>
) {
return lines.map((line, index) => ({
journal_entry_id: entryId,
account_number: line.account_number,
account_id: accountIdMap.get(line.account_number) || null,
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
currency: line.currency || 'SEK',
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
exchange_rate: line.exchange_rate || null,
line_description: line.line_description || null,
tax_code: line.tax_code || null,
cost_center: line.cost_center || null,
project: line.project || null,
sort_order: index,
}))
}
/**
* Create a draft journal entry with lines (no voucher number assigned yet)
* The entry stays in 'draft' status until commitEntry() is called.
*/
export async function createDraftEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
// Validate balance
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new Error(
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
)
}
// Validate that entry_date falls within the selected fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('name, period_start, period_end')
.eq('id', input.fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
if (input.entry_date < period.period_start || input.entry_date > period.period_end) {
throw new Error(
`Entry date ${input.entry_date} is outside fiscal period "${period.name}" (${period.period_start} - ${period.period_end})`
)
}
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
// Validate all account numbers resolved to IDs
const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))]
const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
if (missingAccounts.length > 0) {
throw new Error(
`Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}`
)
}
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: input.fiscal_period_id,
voucher_number: 0,
voucher_series: input.voucher_series || 'A',
entry_date: input.entry_date,
description: input.description,
source_type: input.source_type,
source_id: input.source_id || null,
notes: input.notes || null,
status: 'draft',
})
.select()
.single()
if (entryError || !entry) {
throw new Error(`Failed to create draft journal entry: ${entryError?.message}`)
}
// Insert journal entry lines with dimensions
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(lineInserts)
if (linesError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id)
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
}
// Fetch complete entry with lines
const { data: completeEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entry.id)
.single()
const result = completeEntry as JournalEntry
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: result, userId, companyId },
})
return result
}
/**
* Commit a draft entry: assigns voucher number and transitions to 'posted'
* Uses the atomic commit_journal_entry RPC so the voucher number increment
* and status update happen in one transaction. If the balance trigger rejects
* the entry, the sequence increment rolls back — no burned numbers.
*/
export async function commitEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
commitMethod?: string,
rubricVersion?: string
): Promise<JournalEntry> {
// Atomic: increment voucher sequence + update status in one transaction.
// Rolls back the sequence if the balance trigger or any constraint fails.
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
p_company_id: companyId,
p_entry_id: entryId,
p_commit_method: commitMethod ?? null,
p_rubric_version: rubricVersion ?? null,
})
if (commitError) {
throw new Error(`Failed to commit journal entry: ${commitError.message}`)
}
// Fetch complete posted entry with lines
const { data: completeEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.single()
const result = completeEntry as JournalEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId, companyId },
})
return result
}
/**
* Create a journal entry with lines (verifikation)
* Convenience wrapper: creates draft + commits in one step.
* The voucher number is only assigned after lines are successfully inserted,
* preventing gaps in the voucher sequence (BFL 5 kap. 7§).
*/
export async function createJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateJournalEntryInput,
commitMethod?: string,
rubricVersion?: string
): Promise<JournalEntry> {
const draft = await createDraftEntry(supabase, companyId, userId, input)
return commitEntry(supabase, companyId, userId, draft.id, commitMethod, rubricVersion)
}
/**
* Get the current date in Swedish timezone (Europe/Stockholm).
* Avoids UTC date shift when server runs in a different timezone.
*/
export function getSwedishLocalDate(): string {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(new Date())
}
/**
* Create a reversal entry for an existing journal entry
* Sets reversed_by_id/reverses_id links for compliance tracking
*/
export async function reverseEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
reversalDate?: string
): Promise<JournalEntry> {
// Fetch original entry with lines
const { data: original, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (error || !original) {
throw new Error('Journal entry not found')
}
if (original.status !== 'posted') {
throw new Error('Can only reverse posted entries')
}
const lines = (original.lines as JournalEntryLine[]) || []
// Create reversed lines (swap debit and credit, preserve dimensions)
const reversedLines: CreateJournalEntryLineInput[] = lines.map((line) => ({
account_number: line.account_number,
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: `Reversal: ${line.line_description || ''}`,
currency: line.currency,
amount_in_currency: line.amount_in_currency
? -line.amount_in_currency
: undefined,
exchange_rate: line.exchange_rate || undefined,
tax_code: line.tax_code || undefined,
cost_center: line.cost_center || undefined,
project: line.project || undefined,
}))
const entryDate = reversalDate || getSwedishLocalDate()
// Get voucher number for the reversal
const voucherNumber = await getNextVoucherNumber(
supabase,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines)
// Validate all account numbers resolved to IDs
const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))]
const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num))
if (missingReversalAccounts.length > 0) {
throw new Error(
`Account(s) not found in chart of accounts: ${missingReversalAccounts.join(', ')}`
)
}
// Create reversal entry with reverses_id link
const { data: reversalEntry, error: reversalError } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: original.fiscal_period_id,
voucher_number: voucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: entryDate,
description: `Makulering: ${original.description}`,
source_type: 'storno',
source_id: original.source_id || null,
reverses_id: entryId,
status: 'draft',
})
.select()
.single()
if (reversalError || !reversalEntry) {
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
}
// Insert reversal lines with dimensions
const lineInserts = buildLineInserts(reversalEntry.id, reversedLines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(lineInserts)
if (linesError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${linesError.message}`)
}
// Post the reversal entry
const { error: postError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.eq('id', reversalEntry.id)
if (postError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postError.message}`)
}
// Mark original as reversed with reversed_by_id link (CAS guard: only if still 'posted')
const { data: updatedOriginal, error: casError } = await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', entryId)
.eq('status', 'posted')
.select('id')
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
// Another concurrent reversal already changed the status — mark the orphaned
// reversal as cancelled so it's excluded from reports but remains traceable.
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error('Entry was already reversed by a concurrent operation')
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status
const paymentSourceTypes = [
'invoice_paid', 'invoice_cash_payment',
'supplier_invoice_paid', 'supplier_invoice_cash_payment',
]
if (paymentSourceTypes.includes(original.source_type) && original.source_id) {
// The GL reversal is already handled above (line-by-line mirror of the original
// verifikation per BFL 5 kap 5§). Here we sync the business-level invoice state.
// Payment amounts come from the payments table, not from GL line inspection —
// this works identically for kontantmetod and faktureringsmetod.
const entryId = original.id
if (original.source_type.startsWith('supplier_invoice')) {
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
newStatus = 'overdue'
} else {
newStatus = 'approved'
}
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
paid_amount: Math.max(0, newPaidAmount),
remaining_amount: newRemaining,
paid_at: null,
payment_journal_entry_id: null,
})
.eq('id', original.source_id)
.eq('company_id', companyId)
}
} else {
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
? 'overdue'
: 'sent'
await supabase
.from('invoices')
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
})
.eq('id', original.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
}
}
// Fetch complete reversal entry with lines
const { data: completeEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', reversalEntry.id)
.single()
const result = completeEntry as JournalEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId, companyId },
})
await eventBus.emit({
type: 'journal_entry.reversed',
payload: { originalEntry: original as JournalEntry, reversalEntry: result, userId, companyId },
})
return result
}