Mcp/template data feedback (#617)

* 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>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany, insertFiscalPeriod } from '@/tests/pg/fixtures'
/**
* Recordate (wrong-year fix) DB-layer invariants. recordateEntry moves a posted
* verifikation to a different fiscal year via storno + re-book: the original is
* reversed in its own period and an identical corrected entry is posted in the
* target period with the new date. The service runs through the Supabase JS
* client (out of scope for pg-real, see correct-correction.pg), so we drive the
* SQL directly to prove the guarantees the service depends on:
*
* 1. A correction can be posted into a DIFFERENT open period than the
* original, drawing its voucher number from that period's sequence, while
* the storno + original stay in the original period.
* 2. enforce_period_lock rejects any write into a locked target period — the
* DB backstop behind recordateEntry's pre-flight TargetPeriodLockedError.
*/
describe('recordate (pg-real)', () => {
async function insertDraft(opts: {
userId: string
companyId: string
fiscalPeriodId: string
entryDate: string
sourceType: string
reversesId?: string | null
correctionOfId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status, reverses_id, correction_of_id)
VALUES ($1, $2, $3, $4, 0, 'A', $5, $6, $7, 'draft', $8, $9)`,
[
id,
opts.userId,
opts.companyId,
opts.fiscalPeriodId,
opts.entryDate,
`Entry ${opts.sourceType}`,
opts.sourceType,
opts.reversesId ?? null,
opts.correctionOfId ?? null,
],
)
return id
}
async function insertLines(entryId: string, debitAcc: string, creditAcc: string, amount: number) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[entryId, debitAcc, amount, creditAcc],
)
}
async function commit(companyId: string, entryId: string): Promise<number> {
const { rows } = await getPool().query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryId],
)
return rows[0]!.voucher_number
}
async function markReversed(entryId: string, reversedById: string) {
await getPool().query(
`UPDATE public.journal_entries
SET status = 'reversed', reversed_by_id = $2
WHERE id = $1 AND status = 'posted'`,
[entryId, reversedById],
)
}
it('books the corrected entry in the target year while storno + original stay in the original year', async () => {
const { userId, companyId, fiscalPeriodId: fp2026 } = await seedCompany() // 2026, open
const fp2025 = await insertFiscalPeriod({
userId,
companyId,
name: '2025',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
// Original booked on the wrong year (2026-07-03, should be 2025-07-03).
const originalId = await insertDraft({
userId,
companyId,
fiscalPeriodId: fp2026,
entryDate: '2026-07-03',
sourceType: 'manual',
})
await insertLines(originalId, '6230', '1930', 1008.75)
await commit(companyId, originalId)
// Storno in the original period (nets 2026 to zero for this entry).
const stornoId = await insertDraft({
userId,
companyId,
fiscalPeriodId: fp2026,
entryDate: '2026-07-03',
sourceType: 'storno',
reversesId: originalId,
})
await insertLines(stornoId, '1930', '6230', 1008.75) // swapped legs
await commit(companyId, stornoId)
await markReversed(originalId, stornoId)
// Corrected re-booking in the *target* year with the right date.
const correctedId = await insertDraft({
userId,
companyId,
fiscalPeriodId: fp2025,
entryDate: '2025-07-03',
sourceType: 'correction',
correctionOfId: originalId,
})
await insertLines(correctedId, '6230', '1930', 1008.75)
const correctedVoucher = await commit(companyId, correctedId)
expect(correctedVoucher).toBeGreaterThan(0)
const { rows } = await getPool().query<{
id: string
status: string
fiscal_period_id: string
entry_date: string
correction_of_id: string | null
reverses_id: string | null
reversed_by_id: string | null
}>(
`SELECT id, status, fiscal_period_id, entry_date::text, correction_of_id, reverses_id, reversed_by_id
FROM public.journal_entries WHERE company_id = $1`,
[companyId],
)
const state = Object.fromEntries(rows.map((r) => [r.id, r]))
expect(state[originalId]).toMatchObject({
status: 'reversed',
fiscal_period_id: fp2026,
reversed_by_id: stornoId,
})
expect(state[stornoId]).toMatchObject({
status: 'posted',
fiscal_period_id: fp2026,
reverses_id: originalId,
})
expect(state[correctedId]).toMatchObject({
status: 'posted',
fiscal_period_id: fp2025,
entry_date: '2025-07-03',
correction_of_id: originalId,
})
})
it('enforce_period_lock rejects re-booking into a locked target period', async () => {
const { userId, companyId } = await seedCompany() // 2026, open
const fp2025 = await insertFiscalPeriod({
userId,
companyId,
name: '2025',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
await getPool().query(
`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`,
[fp2025],
)
// The trigger fires on INSERT, so even staging the corrected draft fails.
await expect(
insertDraft({
userId,
companyId,
fiscalPeriodId: fp2025,
entryDate: '2025-07-03',
sourceType: 'correction',
}),
).rejects.toThrow(/locked\/closed fiscal period/)
})
})
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import {
CannotCorrectNonPostedError,
NoOpenPeriodForDateError,
TargetPeriodClosedError,
} from '@/lib/bookkeeping/errors'
// ============================================================
// Mock — sequential results, separate client/builder (see storno-service.test)
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
let inserts: Array<{ table: string; payload: unknown }>
function makeBuilder(table: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'update', 'delete']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.insert = vi.fn().mockImplementation((payload: unknown) => {
inserts.push({ table, payload })
return b
})
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
vi.mock('@/lib/bookkeeping/engine', () => ({
validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1008.75, totalCredit: 1008.75 }),
getNextVoucherNumber: vi.fn(async () => 1),
}))
// resolvePeriodStatusForDate is the classification gate — mock it directly so
// each test controls whether the target date is open/locked/closed/uncovered.
const mockResolve = vi.fn()
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
resolvePeriodStatusForDate: (...args: unknown[]) => mockResolve(...args),
}))
import { recordateEntry } from '../storno-service'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
const original = makeJournalEntry({
id: 'orig-1',
status: 'posted',
description: 'One.com',
entry_date: '2026-07-03',
fiscal_period_id: 'fp-2026',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '6230', debit_amount: 1008.75, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1008.75 }),
],
})
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
resultIdx = 0
results = []
inserts = []
vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1008.75, totalCredit: 1008.75 })
let v = 0
vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++v)
})
describe('recordateEntry', () => {
it('throws no_date_change when the new date equals the current date', async () => {
results = [{ data: original, error: null }]
const supabase = makeClient()
await expect(
recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2026-07-03')
).rejects.toMatchObject({ code: 'MEANINGLESS_CORRECTION', reason: 'no_date_change' })
// Classification is never reached for a no-op move.
expect(mockResolve).not.toHaveBeenCalled()
})
it('rejects a non-posted entry', async () => {
results = [{ data: { ...original, status: 'draft' }, error: null }]
const supabase = makeClient()
await expect(
recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
).rejects.toBeInstanceOf(CannotCorrectNonPostedError)
})
it('refuses to move into a closed fiscal year', async () => {
results = [{ data: original, error: null }]
mockResolve.mockResolvedValue({ status: 'closed', period_id: 'fp-2025', lock_date: null })
const supabase = makeClient()
await expect(
recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
).rejects.toBeInstanceOf(TargetPeriodClosedError)
})
it('refuses to move into a locked period and carries the lock date', async () => {
results = [{ data: original, error: null }]
mockResolve.mockResolvedValue({ status: 'locked', period_id: 'fp-2025', lock_date: '2025-12-31' })
const supabase = makeClient()
await expect(
recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
).rejects.toMatchObject({ code: 'TARGET_PERIOD_LOCKED', lockDate: '2025-12-31' })
})
it('refuses when no fiscal period covers the date', async () => {
results = [{ data: original, error: null }]
mockResolve.mockResolvedValue({ status: 'open', period_id: null, lock_date: null })
const supabase = makeClient()
await expect(
recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
).rejects.toBeInstanceOf(NoOpenPeriodForDateError)
})
it('moves the entry: storno in the original period, corrected in the target period with the new date', async () => {
mockResolve.mockResolvedValue({ status: 'open', period_id: 'fp-2025', lock_date: null })
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
// recordateEntry fetches the original once and hands it to correctEntry via
// preloadedOriginal, so there is no second original fetch in the sequence.
results = [
{ data: original, error: null }, // 0 recordate fetch original
{ data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
{ data: reversalEntry, error: null }, // 2 insert reversal
{ data: null, error: null }, // 3 reversal lines
{ data: null, error: null }, // 4 post reversal
{ data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 5 accounts
{ data: correctedEntry, error: null }, // 6 insert corrected
{ data: null, error: null }, // 7 corrected lines
{ data: null, error: null }, // 8 post corrected
{ data: [{ id: 'orig-1' }], error: null }, // 9 CAS
{ data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
{ data: null, error: null }, // 12 relink documents
]
const supabase = makeClient()
const result = await recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
expect(result.corrected.id).toBe('corrected-1')
const je = inserts
.filter((i) => i.table === 'journal_entries')
.map((i) => i.payload as { source_type: string; fiscal_period_id: string; entry_date: string })
expect(je[0]).toMatchObject({ source_type: 'storno', fiscal_period_id: 'fp-2026', entry_date: '2026-07-03' })
expect(je[1]).toMatchObject({ source_type: 'correction', fiscal_period_id: 'fp-2025', entry_date: '2025-07-03' })
expect(mockResolve).toHaveBeenCalledWith(expect.anything(), 'company-1', '2025-07-03')
})
})
@@ -340,3 +340,92 @@ describe('correctEntry', () => {
)
})
})
describe('correctEntry — date/period override (recordate engine)', () => {
const originalEntry = makeJournalEntry({
id: 'orig-1',
status: 'posted',
description: 'Webbhotell',
entry_date: '2024-06-15',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
],
})
// Same multiset as the original — allowed here because the *date* is the
// change (a wrong-year fix keeps the lines untouched).
const identicalLines = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
it('re-books the corrected entry in the target period/date while the storno stays in the original period', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0 fetch original
{ data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
{ data: reversalEntry, error: null }, // 2 insert reversal
{ data: null, error: null }, // 3 reversal lines
{ data: null, error: null }, // 4 post reversal
{ data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 5 accounts
{ data: correctedEntry, error: null }, // 6 insert corrected
{ data: null, error: null }, // 7 corrected lines
{ data: null, error: null }, // 8 post corrected
{ data: [{ id: 'orig-1' }], error: null }, // 9 CAS
{ data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
]
const supabase = makeClient()
const result = await correctEntry(
supabase as never,
'company-1',
'user-1',
'orig-1',
identicalLines,
{ newEntryDate: '2025-06-15', newFiscalPeriodId: 'fp-2' }
)
expect(result.corrected).toBeDefined()
const je = inserts
.filter((i) => i.table === 'journal_entries')
.map((i) => i.payload as { source_type: string; fiscal_period_id: string; entry_date: string })
expect(je).toHaveLength(2)
expect(je[0]).toMatchObject({ source_type: 'storno', fiscal_period_id: 'fp-1', entry_date: '2024-06-15' })
expect(je[1]).toMatchObject({ source_type: 'correction', fiscal_period_id: 'fp-2', entry_date: '2025-06-15' })
})
it('rejects when the new date falls outside the target period bounds', async () => {
results = [
{ data: originalEntry, error: null }, // 0 fetch original
{ data: { name: '2025', period_start: '2025-01-01', period_end: '2025-05-31' }, error: null }, // 1 target period — 06-15 out of bounds
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines, {
newEntryDate: '2025-06-15',
newFiscalPeriodId: 'fp-2',
})
).rejects.toMatchObject({ code: 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD' })
// No storno should have been written.
expect(inserts.filter((i) => i.table === 'journal_entries')).toHaveLength(0)
})
it('rejects when the target period cannot be found', async () => {
results = [
{ data: originalEntry, error: null }, // 0 fetch original
{ data: null, error: { message: 'no rows' } }, // 1 target period missing
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines, {
newEntryDate: '2025-06-15',
newFiscalPeriodId: 'fp-2',
})
).rejects.toMatchObject({ code: 'FISCAL_PERIOD_NOT_FOUND' })
})
})
+205 -16
View File
@@ -6,14 +6,20 @@ import type {
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'
/**
@@ -90,9 +96,20 @@ async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise<v
}
}
/** 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(
@@ -100,7 +117,18 @@ export async function correctEntry(
companyId: string,
userId: string,
originalEntryId: string,
correctedLines: CreateJournalEntryLineInput[]
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)
@@ -115,16 +143,20 @@ export async function correctEntry(
throw new MeaninglessCorrectionError('net_zero_per_account')
}
// Fetch original entry 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()
// 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 || !original) {
throw new JournalEntryNotFoundError()
if (fetchError || !data) {
throw new JournalEntryNotFoundError()
}
original = data as OriginalWithLines
}
if (original.status !== 'posted') {
@@ -133,12 +165,45 @@ export async function correctEntry(
const originalLines = (original.lines as JournalEntryLine[]) || []
// Reject when the proposed lines are identical to the original entry —
// a rättelse must actually change something.
if (isIdenticalToOriginal(correctedLines, originalLines)) {
// 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,
@@ -219,7 +284,7 @@ export async function correctEntry(
const correctedVoucherNumber = await getNextVoucherNumber(
supabase,
companyId,
original.fiscal_period_id,
correctedPeriodId,
original.voucher_series || 'A'
)
@@ -248,10 +313,10 @@ export async function correctEntry(
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: original.fiscal_period_id,
fiscal_period_id: correctedPeriodId,
voucher_number: correctedVoucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: original.entry_date,
entry_date: correctedDate,
description: `Rättelse: ${original.description}`,
source_type: 'correction',
correction_of_id: originalEntryId,
@@ -360,3 +425,127 @@ export async function correctEntry(
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
)
}
}