* fix(booking-templates): scope template list to the active company GET /api/settings/booking-templates relied solely on the btl_select RLS policy, which is membership-wide (user_company_ids) and returns templates from every company the user belongs to. A user who owns multiple companies saw all their templates merged regardless of which company was active. Narrow the list in the API layer (mirroring counterparty-templates) to system + the active company + the active company's team. RLS stays the security backstop; this fixes the cross-company merge within a single user's own view (it was never a cross-tenant data leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): show proper message for duplicate bank file upload The bank file import page mis-parsed the structured error envelope ({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE (409) fell through to the generic "Kunde inte läsa filen" fallback. The upload step also hardcoded that same string as the error heading, so duplicates were doubly misreported as parse failures. - Parse the structured envelope by error.code; surface error.message for all codes instead of rendering the error object. - Add a dedicated BANK_FILE_DUPLICATE message using the importedAt / importedCount details the route already returns. - Add an optional errorTitle prop to BankFileUploadStep (defaults to the previous text) and pass "Filen är redan importerad" for dupes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling - Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions. - Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates. - Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources. - Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability. feat(migrations): add new database migrations for transaction handling - Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation. - Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity. * feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines * feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { eventBus } from '@/lib/events'
|
||
import type {
|
||
CreateJournalEntryLineInput,
|
||
JournalEntry,
|
||
JournalEntryLine,
|
||
} from '@/types'
|
||
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||
import {
|
||
AccountsNotInChartError,
|
||
BookkeepingDatabaseError,
|
||
CannotCorrectNonPostedError,
|
||
EntryAlreadyReversedError,
|
||
EntryDateOutsideFiscalPeriodError,
|
||
FiscalPeriodNotFoundError,
|
||
JournalEntryNotBalancedError,
|
||
JournalEntryNotFoundError,
|
||
MeaninglessCorrectionError,
|
||
NoOpenPeriodForDateError,
|
||
TargetPeriodClosedError,
|
||
TargetPeriodLockedError,
|
||
} from '@/lib/bookkeeping/errors'
|
||
|
||
/**
|
||
* Round to 2dp using cents-integer math to avoid 0.1+0.2 drift.
|
||
*/
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* True when every account's (debit − credit) sum across the proposed lines is
|
||
* zero. Such a rättelse describes no real affärshändelse and would erase the
|
||
* original posting without representing anything in its place — disallowed by
|
||
* BFL 5 kap. 5 § / BFNAR 2013:2.
|
||
*/
|
||
function netsToZeroPerAccount(lines: CreateJournalEntryLineInput[]): boolean {
|
||
const nets = new Map<string, number>()
|
||
for (const line of lines) {
|
||
const delta = round2(line.debit_amount || 0) - round2(line.credit_amount || 0)
|
||
nets.set(line.account_number, (nets.get(line.account_number) || 0) + delta)
|
||
}
|
||
return Array.from(nets.values()).every((n) => Math.abs(n) < 0.005)
|
||
}
|
||
|
||
/**
|
||
* True when proposed lines are the same multiset as the original lines
|
||
* (account_number + debit + credit). A rättelse must actually change something.
|
||
*/
|
||
function isIdenticalToOriginal(
|
||
proposed: CreateJournalEntryLineInput[],
|
||
original: JournalEntryLine[]
|
||
): boolean {
|
||
if (proposed.length !== original.length) return false
|
||
const key = (acc: string, d: number, c: number) =>
|
||
`${acc}|${round2(d).toFixed(2)}|${round2(c).toFixed(2)}`
|
||
const proposedKeys = proposed
|
||
.map((l) => key(l.account_number, l.debit_amount || 0, l.credit_amount || 0))
|
||
.sort()
|
||
const originalKeys = original
|
||
.map((l) => key(l.account_number, Number(l.debit_amount) || 0, Number(l.credit_amount) || 0))
|
||
.sort()
|
||
return proposedKeys.every((k, i) => k === originalKeys[i])
|
||
}
|
||
|
||
/**
|
||
* Storno Service - 3-step correction flow per Bokföringslagen
|
||
*
|
||
* Swedish bookkeeping law requires that committed entries cannot be modified.
|
||
* To correct an error, you must:
|
||
* 1. Create a storno (reversal) entry that nullifies the original
|
||
* 2. Create a corrected entry with the right data
|
||
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
|
||
*/
|
||
|
||
/**
|
||
* Cancel a journal entry and delete its lines.
|
||
* Uses status='cancelled' instead of DELETE (DB trigger blocks all DELETEs).
|
||
* Works for both draft→cancelled and posted→cancelled transitions.
|
||
*/
|
||
async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise<void> {
|
||
const { error: statusErr } = await supabase
|
||
.from('journal_entries')
|
||
.update({ status: 'cancelled' })
|
||
.eq('id', entryId)
|
||
if (statusErr) {
|
||
console.error(`[storno] cancelEntry: failed to cancel ${entryId}:`, statusErr.message)
|
||
}
|
||
const { error: linesErr } = await supabase
|
||
.from('journal_entry_lines')
|
||
.delete()
|
||
.eq('journal_entry_id', entryId)
|
||
if (linesErr) {
|
||
console.error(`[storno] cancelEntry: failed to delete lines for ${entryId}:`, linesErr.message)
|
||
}
|
||
}
|
||
|
||
/** Journal entry row fetched together with its lines (the embedded select). */
|
||
type OriginalWithLines = JournalEntry & { lines?: JournalEntryLine[] | null }
|
||
|
||
/**
|
||
* Correct an existing posted journal entry using the storno method.
|
||
*
|
||
* The storno (reversal) is always created in the original entry's period and
|
||
* date, so the original nets to zero where it was booked. The corrected entry
|
||
* defaults to the original's date/period too, but `options.newEntryDate` /
|
||
* `options.newFiscalPeriodId` let a caller re-book it elsewhere — used to move
|
||
* a verifikation booked on the wrong year to its correct period (see
|
||
* recordateEntry). When the date/period is the correction, identical lines are
|
||
* allowed (the move itself is the meaningful change).
|
||
*
|
||
* Returns: { reversal, corrected } - the two new entries created
|
||
*/
|
||
export async function correctEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
originalEntryId: string,
|
||
correctedLines: CreateJournalEntryLineInput[],
|
||
options?: {
|
||
newEntryDate?: string
|
||
newFiscalPeriodId?: string
|
||
/**
|
||
* The original entry (with lines) already loaded by the caller. When
|
||
* provided, we skip the redundant re-fetch — recordateEntry reads the
|
||
* original to copy its lines and hands it through here. This also closes
|
||
* the small TOCTOU window a second independent read would open.
|
||
*/
|
||
preloadedOriginal?: OriginalWithLines
|
||
}
|
||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
||
// Validate the corrected lines are balanced
|
||
const balance = validateBalance(correctedLines)
|
||
if (!balance.valid) {
|
||
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'correction')
|
||
}
|
||
|
||
// Reject a rättelse with no economic effect (e.g. 1930 debit 100 / 1930
|
||
// credit 100). Such an entry would erase the original posting without
|
||
// representing any affärshändelse — disallowed by BFL 5 kap. 5 §.
|
||
if (netsToZeroPerAccount(correctedLines)) {
|
||
throw new MeaninglessCorrectionError('net_zero_per_account')
|
||
}
|
||
|
||
// Fetch original entry with lines — unless the caller already loaded it.
|
||
let original = options?.preloadedOriginal ?? null
|
||
if (!original) {
|
||
const { data, error: fetchError } = await supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)')
|
||
.eq('id', originalEntryId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !data) {
|
||
throw new JournalEntryNotFoundError()
|
||
}
|
||
original = data as OriginalWithLines
|
||
}
|
||
|
||
if (original.status !== 'posted') {
|
||
throw new CannotCorrectNonPostedError(original.status)
|
||
}
|
||
|
||
const originalLines = (original.lines as JournalEntryLine[]) || []
|
||
|
||
// Resolve where the corrected entry lands. Defaults to the original's own
|
||
// date/period (a plain line-correction). A caller may override either to
|
||
// re-book the entry in another period (recordate / wrong-year fix).
|
||
const correctedDate = options?.newEntryDate ?? original.entry_date
|
||
const correctedPeriodId = options?.newFiscalPeriodId ?? original.fiscal_period_id
|
||
const dateOrPeriodChanged =
|
||
correctedDate !== original.entry_date || correctedPeriodId !== original.fiscal_period_id
|
||
|
||
// Reject when the proposed lines are identical to the original entry — a
|
||
// rättelse must actually change something. Skip this when the date/period is
|
||
// the change (moving a verifikation to the right year keeps the same lines).
|
||
if (!dateOrPeriodChanged && isIdenticalToOriginal(correctedLines, originalLines)) {
|
||
throw new MeaninglessCorrectionError('identical_to_original')
|
||
}
|
||
|
||
// When re-booking elsewhere, validate the corrected date falls within the
|
||
// target period's bounds (mirrors createDraftEntry). recordateEntry resolves
|
||
// the period from the date, so this also guards a mismatched explicit
|
||
// override and fails fast before any storno is written.
|
||
if (dateOrPeriodChanged) {
|
||
const { data: targetPeriod, error: targetErr } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('name, period_start, period_end')
|
||
.eq('id', correctedPeriodId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
if (targetErr || !targetPeriod) {
|
||
throw new FiscalPeriodNotFoundError()
|
||
}
|
||
if (correctedDate < targetPeriod.period_start || correctedDate > targetPeriod.period_end) {
|
||
throw new EntryDateOutsideFiscalPeriodError(
|
||
correctedDate,
|
||
targetPeriod.name,
|
||
targetPeriod.period_start,
|
||
targetPeriod.period_end
|
||
)
|
||
}
|
||
}
|
||
|
||
// ===== Step 1: Create storno (reversal) entry =====
|
||
const reversalVoucherNumber = await getNextVoucherNumber(
|
||
supabase,
|
||
companyId,
|
||
original.fiscal_period_id,
|
||
original.voucher_series || 'A'
|
||
)
|
||
|
||
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: reversalVoucherNumber,
|
||
voucher_series: original.voucher_series || 'A',
|
||
entry_date: original.entry_date,
|
||
description: `Storno: ${original.description}`,
|
||
source_type: 'storno',
|
||
reverses_id: originalEntryId,
|
||
status: 'draft',
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (reversalError || !reversalEntry) {
|
||
throw new BookkeepingDatabaseError('create_reversal_entry', reversalError?.message)
|
||
}
|
||
|
||
// Insert reversed lines (swap debit and credit)
|
||
const reversalLineInserts = originalLines.map((line, index) => ({
|
||
journal_entry_id: reversalEntry.id,
|
||
account_number: line.account_number,
|
||
account_id: line.account_id || null,
|
||
debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
|
||
credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
|
||
currency: line.currency || 'SEK',
|
||
amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null,
|
||
exchange_rate: line.exchange_rate || null,
|
||
line_description: `Storno: ${line.line_description || ''}`,
|
||
tax_code: line.tax_code || null,
|
||
cost_center: line.cost_center || null,
|
||
project: line.project || null,
|
||
sort_order: index,
|
||
}))
|
||
|
||
const { error: reversalLinesError } = await supabase
|
||
.from('journal_entry_lines')
|
||
.insert(reversalLineInserts)
|
||
|
||
if (reversalLinesError) {
|
||
await cancelEntry(supabase, reversalEntry.id)
|
||
throw new BookkeepingDatabaseError('create_reversal_lines', reversalLinesError.message)
|
||
}
|
||
|
||
// Post the reversal entry
|
||
const { error: postReversalError } = await supabase
|
||
.from('journal_entries')
|
||
.update({ status: 'posted' })
|
||
.eq('id', reversalEntry.id)
|
||
|
||
if (postReversalError) {
|
||
await cancelEntry(supabase, reversalEntry.id)
|
||
throw new BookkeepingDatabaseError('post_reversal_entry', postReversalError.message)
|
||
}
|
||
|
||
// NOTE: Original entry is NOT marked as 'reversed' here. We defer that
|
||
// until both the reversal and corrected entries are successfully posted.
|
||
// This avoids the impossible reversed→posted rollback if step 2 fails.
|
||
|
||
// ===== Step 2: Create corrected entry =====
|
||
// If anything in this step fails, cancel the reversal entry.
|
||
// The original entry was never modified, so no rollback needed.
|
||
|
||
let correctedEntry: typeof reversalEntry
|
||
|
||
try {
|
||
const correctedVoucherNumber = await getNextVoucherNumber(
|
||
supabase,
|
||
companyId,
|
||
correctedPeriodId,
|
||
original.voucher_series || 'A'
|
||
)
|
||
|
||
// Resolve account IDs for corrected lines — only active rows count
|
||
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
|
||
const { data: accounts } = await supabase
|
||
.from('chart_of_accounts')
|
||
.select('id, account_number')
|
||
.eq('company_id', companyId)
|
||
.eq('is_active', true)
|
||
.in('account_number', accountNumbers)
|
||
|
||
const accountIdMap = new Map<string, string>()
|
||
for (const account of accounts || []) {
|
||
accountIdMap.set(account.account_number, account.id)
|
||
}
|
||
|
||
// Validate all account numbers resolved to IDs
|
||
const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num))
|
||
if (missingAccounts.length > 0) {
|
||
throw new AccountsNotInChartError(missingAccounts)
|
||
}
|
||
|
||
const { data: newEntry, error: correctedError } = await supabase
|
||
.from('journal_entries')
|
||
.insert({
|
||
company_id: companyId,
|
||
user_id: userId,
|
||
fiscal_period_id: correctedPeriodId,
|
||
voucher_number: correctedVoucherNumber,
|
||
voucher_series: original.voucher_series || 'A',
|
||
entry_date: correctedDate,
|
||
description: `Rättelse: ${original.description}`,
|
||
source_type: 'correction',
|
||
correction_of_id: originalEntryId,
|
||
status: 'draft',
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (correctedError || !newEntry) {
|
||
throw new BookkeepingDatabaseError('create_corrected_entry', correctedError?.message)
|
||
}
|
||
|
||
correctedEntry = newEntry
|
||
|
||
// Insert corrected lines
|
||
const correctedLineInserts = correctedLines.map((line, index) => ({
|
||
journal_entry_id: correctedEntry.id,
|
||
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,
|
||
}))
|
||
|
||
const { error: correctedLinesError } = await supabase
|
||
.from('journal_entry_lines')
|
||
.insert(correctedLineInserts)
|
||
|
||
if (correctedLinesError) {
|
||
await cancelEntry(supabase, correctedEntry.id)
|
||
throw new BookkeepingDatabaseError('create_corrected_lines', correctedLinesError.message)
|
||
}
|
||
|
||
// Post the corrected entry
|
||
const { error: postCorrectedError } = await supabase
|
||
.from('journal_entries')
|
||
.update({ status: 'posted' })
|
||
.eq('id', correctedEntry.id)
|
||
|
||
if (postCorrectedError) {
|
||
await cancelEntry(supabase, correctedEntry.id)
|
||
throw new BookkeepingDatabaseError('post_corrected_entry', postCorrectedError.message)
|
||
}
|
||
} catch (err) {
|
||
// Cancel the reversal entry (posted → cancelled). Original was never
|
||
// modified so no rollback needed — it's still 'posted'.
|
||
await cancelEntry(supabase, reversalEntry.id)
|
||
throw err
|
||
}
|
||
|
||
// ===== Mark original as reversed (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', originalEntryId)
|
||
.eq('status', 'posted')
|
||
.select('id')
|
||
|
||
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
|
||
// Concurrent reversal beat us — cancel both our entries
|
||
await cancelEntry(supabase, reversalEntry.id)
|
||
await cancelEntry(supabase, correctedEntry!.id)
|
||
throw new EntryAlreadyReversedError()
|
||
}
|
||
|
||
// ===== Step 3: Fetch complete entries =====
|
||
const { data: finalReversal } = await supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)')
|
||
.eq('id', reversalEntry.id)
|
||
.single()
|
||
|
||
const { data: finalCorrected } = await supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)')
|
||
.eq('id', correctedEntry.id)
|
||
.single()
|
||
|
||
const result = {
|
||
reversal: finalReversal as JournalEntry,
|
||
corrected: finalCorrected as JournalEntry,
|
||
}
|
||
|
||
await eventBus.emit({
|
||
type: 'journal_entry.corrected',
|
||
payload: {
|
||
original: original as JournalEntry,
|
||
storno: result.reversal,
|
||
corrected: result.corrected,
|
||
companyId,
|
||
userId,
|
||
},
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Move a posted verifikation to a different date — and thereby a different
|
||
* fiscal period — without changing its lines. Fixes a booking entered with the
|
||
* wrong date/year (e.g. 2026-07-03 that should have been 2025-07-03).
|
||
*
|
||
* A posted verifikation is immutable (BFL), so this is a storno + re-book under
|
||
* the hood: the original is reversed in its own period (netting it to zero
|
||
* there) and an identical corrected verifikation is posted with `newDate` in
|
||
* the target period. The underlag follows the corrected entry. The full chain
|
||
* original → storno → correction stays linked (BFL 5 kap. 5 §).
|
||
*
|
||
* Fails fast with a typed error if the target date is not bookable: closed year
|
||
* (TargetPeriodClosedError), locked period / company lock date
|
||
* (TargetPeriodLockedError), or no covering period (NoOpenPeriodForDateError).
|
||
*/
|
||
export async function recordateEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
originalEntryId: string,
|
||
newDate: string
|
||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
||
// Fetch original with lines
|
||
const { data: original, error: fetchError } = await supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)')
|
||
.eq('id', originalEntryId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !original) {
|
||
throw new JournalEntryNotFoundError()
|
||
}
|
||
if (original.status !== 'posted') {
|
||
throw new CannotCorrectNonPostedError(original.status)
|
||
}
|
||
if (newDate === original.entry_date) {
|
||
throw new MeaninglessCorrectionError('no_date_change')
|
||
}
|
||
|
||
// Classify the target date using the same two-layer logic the DB triggers
|
||
// enforce (company lock date + period is_closed/locked_at), so we surface a
|
||
// clear Swedish message instead of a raw trigger rejection.
|
||
const target = await resolvePeriodStatusForDate(supabase, companyId, newDate)
|
||
if (target.status === 'closed') {
|
||
throw new TargetPeriodClosedError(newDate)
|
||
}
|
||
if (target.status === 'locked') {
|
||
throw new TargetPeriodLockedError(newDate, target.lock_date)
|
||
}
|
||
if (!target.period_id) {
|
||
// 'open' but no covering period — we do not auto-create periods on a fix.
|
||
throw new NoOpenPeriodForDateError(newDate)
|
||
}
|
||
|
||
// Copy the original lines verbatim — they were correct; only the date was
|
||
// wrong. correctEntry rebuilds the storno from the original anyway.
|
||
const originalLines = (original.lines as JournalEntryLine[]) || []
|
||
const copiedLines: CreateJournalEntryLineInput[] = originalLines
|
||
.slice()
|
||
.sort((a, b) => a.sort_order - b.sort_order)
|
||
.map((line) => ({
|
||
account_number: line.account_number,
|
||
debit_amount: Number(line.debit_amount) || 0,
|
||
credit_amount: Number(line.credit_amount) || 0,
|
||
line_description: line.line_description || undefined,
|
||
currency: line.currency || undefined,
|
||
amount_in_currency:
|
||
line.amount_in_currency != null ? Number(line.amount_in_currency) : undefined,
|
||
exchange_rate: line.exchange_rate != null ? Number(line.exchange_rate) : undefined,
|
||
tax_code: line.tax_code || undefined,
|
||
cost_center: line.cost_center || undefined,
|
||
project: line.project || undefined,
|
||
}))
|
||
|
||
const result = await correctEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
originalEntryId,
|
||
copiedLines,
|
||
{
|
||
newEntryDate: newDate,
|
||
newFiscalPeriodId: target.period_id,
|
||
// Hand the entry we already fetched (with lines) to correctEntry so it
|
||
// doesn't re-read the same row.
|
||
preloadedOriginal: original as OriginalWithLines,
|
||
}
|
||
)
|
||
|
||
// Move the underlag to the corrected entry so it doesn't surface as a
|
||
// "verifikat utan underlag" in the target year. Best-effort — the
|
||
// correction_of_id chain preserves traceability even if this fails.
|
||
await relinkDocumentsToEntry(supabase, companyId, originalEntryId, result.corrected.id)
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Re-point every document_attachment from one entry to another. Used when a
|
||
* verifikation is moved to a different period so its underlag travels with the
|
||
* live (corrected) entry. The line-level link is cleared because the corrected
|
||
* entry has new line ids. Failures are logged, not thrown — the entry-level
|
||
* correction chain is the source of truth for traceability.
|
||
*/
|
||
async function relinkDocumentsToEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fromEntryId: string,
|
||
toEntryId: string
|
||
): Promise<void> {
|
||
const { error } = await supabase
|
||
.from('document_attachments')
|
||
.update({ journal_entry_id: toEntryId, journal_entry_line_id: null })
|
||
.eq('company_id', companyId)
|
||
.eq('journal_entry_id', fromEntryId)
|
||
if (error) {
|
||
console.error(
|
||
`[storno] relinkDocumentsToEntry: failed to move documents ${fromEntryId} → ${toEntryId}:`,
|
||
error.message
|
||
)
|
||
}
|
||
}
|