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
+18
View File
@@ -458,6 +458,15 @@ export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
/**
* Move a posted verifikation to a different date (and thereby fiscal period)
* without changing its lines — fixes a booking entered with the wrong
* date/year. The corrected lines are copied server-side from the original.
*/
export const RecordateJournalEntrySchema = z.object({
new_entry_date: isoDate,
})
// ============================================================
// Transaction schemas
// ============================================================
@@ -481,6 +490,15 @@ export const BookTransactionSchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
})
/**
* Edit a bank transaction's title (description). Only the working label —
* gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is
* rejected by min(1). Passing the bank original restores the "not edited" tag.
*/
export const UpdateTransactionTitleSchema = z.object({
description: z.string().trim().min(1, 'Title cannot be empty').max(500),
})
export const BookInboxItemDirectlySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
@@ -12,6 +12,8 @@ function makeTx(overrides: Partial<Transaction> = {}): Transaction {
external_id: 'eb_sek_1',
date: '2026-06-12',
description: 'Överföring till EUR-konto',
original_description: 'Överföring till EUR-konto',
title_edited_at: null,
amount: -1000,
currency: 'SEK',
amount_sek: -1000,
+96 -3
View File
@@ -16,6 +16,9 @@ export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
export const NO_OPEN_PERIOD_FOR_DATE = 'NO_OPEN_PERIOD_FOR_DATE' as const
export const TARGET_PERIOD_CLOSED = 'TARGET_PERIOD_CLOSED' as const
export const TARGET_PERIOD_LOCKED = 'TARGET_PERIOD_LOCKED' as const
// ============================================================================
// AccountsNotInChartError — kept for back-compat (many existing call sites)
@@ -139,7 +142,10 @@ export class CurrencyRevaluationAlreadyExistsError extends Error {
}
}
export type MeaninglessCorrectionReason = 'net_zero_per_account' | 'identical_to_original'
export type MeaninglessCorrectionReason =
| 'net_zero_per_account'
| 'identical_to_original'
| 'no_date_change'
export class MeaninglessCorrectionError extends Error {
readonly code = MEANINGLESS_CORRECTION
@@ -147,12 +153,57 @@ export class MeaninglessCorrectionError extends Error {
super(
reason === 'net_zero_per_account'
? 'Correction lines net to zero on every account — no economic event represented (BFL 5 kap. 5 §).'
: 'Correction lines are identical to the original entry — nothing to correct.'
: reason === 'no_date_change'
? 'New date equals the current date — nothing to move.'
: 'Correction lines are identical to the original entry — nothing to correct.'
)
this.name = 'MeaninglessCorrectionError'
}
}
/**
* Raised when a verifikation is moved (recordate) to a date that no fiscal
* period covers. We do not auto-create periods on a correction.
*/
export class NoOpenPeriodForDateError extends Error {
readonly code = NO_OPEN_PERIOD_FOR_DATE
constructor(public readonly date: string) {
super(`No fiscal period covers ${date}`)
this.name = 'NoOpenPeriodForDateError'
}
}
/**
* Raised when the target date of a recordate falls in a closed fiscal year
* (bokslut). A closed year cannot be reopened — the correction must be booked
* in the current open period instead.
*/
export class TargetPeriodClosedError extends Error {
readonly code = TARGET_PERIOD_CLOSED
constructor(public readonly date: string) {
super(`The fiscal period covering ${date} is closed`)
this.name = 'TargetPeriodClosedError'
}
}
/**
* Raised when the target date of a recordate falls in a locked period or is
* covered by the company-wide bookkeeping lock date. Carries the lock date so
* the UI can offer an unlock affordance.
*/
export class TargetPeriodLockedError extends Error {
readonly code = TARGET_PERIOD_LOCKED
constructor(
public readonly date: string,
public readonly lockDate: string | null
) {
super(
`The fiscal period covering ${date} is locked${lockDate ? ` (lock date ${lockDate})` : ''}`
)
this.name = 'TargetPeriodLockedError'
}
}
export class InvalidMappingResultError extends Error {
readonly code = INVALID_MAPPING_RESULT
constructor(
@@ -222,7 +273,10 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof CurrencyRevaluationAlreadyExistsError ||
err instanceof InvalidMappingResultError ||
err instanceof BookkeepingDatabaseError ||
err instanceof MeaninglessCorrectionError
err instanceof MeaninglessCorrectionError ||
err instanceof NoOpenPeriodForDateError ||
err instanceof TargetPeriodClosedError ||
err instanceof TargetPeriodLockedError
)
}
@@ -385,6 +439,45 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof NoOpenPeriodForDateError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { date: err.date },
},
},
{ status: 400 }
)
}
if (err instanceof TargetPeriodClosedError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { date: err.date },
},
},
{ status: 409 }
)
}
if (err instanceof TargetPeriodLockedError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { date: err.date, lockDate: err.lockDate },
},
},
{ status: 409 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
@@ -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
)
}
}
+18
View File
@@ -324,8 +324,26 @@ export function getErrorMessage(
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
if (structured.code === 'NO_OPEN_PERIOD_FOR_DATE') {
return 'Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.'
}
if (structured.code === 'TARGET_PERIOD_CLOSED') {
return 'Räkenskapsåret för det valda datumet är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället.'
}
if (structured.code === 'TARGET_PERIOD_LOCKED') {
const details = structured.details as { lockDate?: string } | undefined
return details?.lockDate
? `Räkenskapsperioden för det valda datumet är låst (t.o.m. ${details.lockDate}). Lås upp perioden för att flytta verifikationen dit.`
: 'Räkenskapsperioden för det valda datumet är låst. Lås upp perioden för att flytta verifikationen dit.'
}
if (structured.code === 'MEANINGLESS_CORRECTION') {
const details = structured.details as { reason?: string } | undefined
if (details?.reason === 'no_date_change') {
return 'Det nya datumet är samma som det nuvarande — det finns inget att flytta.'
}
if (details?.reason === 'identical_to_original') {
return 'Rättelsen är identisk med originalverifikationen — inget har ändrats.'
}
+14
View File
@@ -259,6 +259,13 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
message_sv: 'Transaktionen kunde inte hittas.',
message_en: 'Transaction not found.',
},
TRANSACTION_TITLE_LOCKED: {
httpStatus: 409,
message_sv:
'Det går inte att ändra titeln på en bokförd eller matchad transaktion. Bokförda verifikat rättas med storno.',
message_en:
'Cannot edit the title of a booked or matched transaction. Posted vouchers are corrected with storno.',
},
TX_CATEGORIZE_INVALID_ACCOUNT: {
httpStatus: 400,
message_sv: 'Det valda kontot finns inte i kontoplanen.',
@@ -1416,6 +1423,13 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.',
message_en: 'Invalid combination of supplier invoice fields.',
},
SI_CREATE_NO_FISCAL_PERIOD: {
httpStatus: 400,
message_sv:
'Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.',
message_en:
'No fiscal year covers the invoice date. Create the fiscal year first, or change the invoice date.',
},
SI_PAID_ALREADY: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
@@ -79,6 +79,34 @@ const HANDELSBANKEN_CSV_WITH_PREL = [
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
].join('\n')
// Real Handelsbanken web exports can prepend account/period metadata rows
// (and a blank line) before the actual column header.
const HANDELSBANKEN_CSV_WITH_PREAMBLE = [
'Kontonummer;6789 123 456 789',
'Kontohavare;Wiklund, Cristel',
'Period;2024-01-01 - 2024-01-31',
'',
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
// Negative amounts exported with a Unicode minus (U+2212) instead of ASCII '-'.
const HANDELSBANKEN_CSV_UNICODE_MINUS = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;−139,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;−1 432,50;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
// A quoted Text field that itself contains the semicolon delimiter.
const HANDELSBANKEN_CSV_QUOTED_SEMICOLON = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;"BETALNING; FAKTURA 100";-99,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
].join('\n')
const CAMT053_XML = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
@@ -877,6 +905,47 @@ describe('parseBankFile — Handelsbanken format', () => {
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
})
it('detects Handelsbanken CSV when a metadata preamble precedes the header', () => {
const format = detectFileFormat(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('skips the metadata preamble rows and parses the transactions', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
expect(result.format).toBe('handelsbanken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(0)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain('Kontonummer')
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[2].amount).toBe(25000)
})
it('parses negative amounts that use a Unicode minus (U+2212) instead of dropping them', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_UNICODE_MINUS, 'shb.csv')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(0)
expect(result.transactions[0].amount).toBe(-139)
expect(result.transactions[1].amount).toBe(-1432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('handles a quoted Text field that contains the semicolon delimiter', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_QUOTED_SEMICOLON, 'shb.csv')
expect(result.transactions).toHaveLength(2)
expect(result.issues).toHaveLength(0)
expect(result.transactions[0].description).toBe('BETALNING; FAKTURA 100')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
})
describe('parseBankFile — Länsförsäkringar format', () => {
+70 -30
View File
@@ -7,18 +7,45 @@
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Filter rows with "Prel" prefix (preliminary/pending transactions)
* - Real Handelsbanken exports may prepend metadata rows (account number, period,
* balance) before the column header, so we scan the first lines for the header
* rather than assuming it is line 0.
* - Fields may be double-quoted and a quoted "Text" field can itself contain a
* semicolon, so we use the quote-aware parseCSVLine rather than split(';').
* - Negative amounts may use a Unicode minus (U+2212) or dash; normalizeMinusSign
* maps those to ASCII '-' so parseFloat does not return NaN.
* - Filter rows with "Prel" prefix (preliminary/pending transactions).
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
import { normalizeMinusSign } from './generic-csv'
// How many leading lines to scan for the header (allows for a metadata preamble)
const HEADER_SCAN_LIMIT = 15
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
// Swedish format "1 234,56" / "-1 234,56", tolerating Unicode minus on negatives
const cleaned = normalizeMinusSign(value).replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
/**
* Check if a line is the Handelsbanken transaction header: semicolon-delimited
* and carrying a Handelsbanken date column plus the amount column. Specific
* enough not to steal SEB / Nordea Företag / ICA / Skandia files, which use
* different date labels (bokföringsdag, valutadag, datum).
*/
function isHandelsbankenHeader(line: string): boolean {
const lower = line.toLowerCase()
if (!lower.includes(';')) return false
const hasDate = lower.includes('reskontradatum') || lower.includes('transaktionsdatum')
const hasAmount = lower.includes('belopp')
return hasDate && hasAmount
}
export const handelsbankenFormat: BankFileFormat = {
id: 'handelsbanken',
name: 'Handelsbanken',
@@ -27,11 +54,8 @@ export const handelsbankenFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
return (
firstLine.includes(';') &&
(firstLine.includes('reskontradatum') || firstLine.includes('transaktionsdatum'))
)
const lines = prepared.split('\n')
return lines.slice(0, HEADER_SCAN_LIMIT).some(isHandelsbankenHeader)
},
parse(content: string): BankFileParseResult {
@@ -42,43 +66,59 @@ export const handelsbankenFormat: BankFileFormat = {
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const emptyResult = (issue: BankFileParseIssue): BankFileParseResult => ({
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions: [],
date_from: null,
date_to: null,
issues: [issue],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
})
const dateIdx = headers.findIndex(
(h) => h.includes('reskontradatum') || h.includes('transaktionsdatum')
// Find the header row, skipping any metadata preamble.
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, HEADER_SCAN_LIMIT); i++) {
if (isHandelsbankenHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (headerLineIdx === -1) {
return emptyResult({
row: 1,
message: 'Kunde inte hitta rubrikraden (Transaktionsdatum/Reskontradatum, Belopp).',
severity: 'error',
})
}
const headers = parseCSVLine(lines[headerLineIdx], ';').map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
const reskontraIdx = headers.findIndex((h) => h.includes('reskontradatum'))
const txDateIdx = headers.findIndex((h) => h.includes('transaktionsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
// Prefer transaktionsdatum (real transaction date) over reskontradatum (booking date)
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : reskontraIdx
if (primaryDateIdx === -1 || amountIdx === -1) {
return emptyResult({
row: headerLineIdx + 1,
message: 'Could not identify required columns',
severity: 'error',
})
return {
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Prefer transaktionsdatum over reskontradatum if available
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : dateIdx
for (let i = 1; i < lines.length; i++) {
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[primaryDateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
@@ -138,7 +178,7 @@ export const handelsbankenFormat: BankFileFormat = {
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
@@ -0,0 +1,244 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* Regression guard for the inbox-linking step of the two "from inbox" commit
* handlers in lib/pending-operations/commit.ts:
*
* - commitCreateVoucher (book-direct kvitto → verifikat)
* - commitCreateSupplierInvoiceFromInbox (inbox → leverantörsfaktura)
*
* Both resolve the source inbox row on approval by stamping a terminal link
* column (created_journal_entry_id / created_supplier_invoice_id). An earlier
* version of both handlers ALSO wrote `status: 'confirmed'` in that same
* UPDATE. But migration 20260504180000 tightened the status enum to
* `CHECK (status IN ('received','error'))` — 'confirmed' is no longer legal.
*
* Because the link column and the illegal status were set in ONE atomic
* UPDATE, Postgres rejected the whole statement. The handler swallowed the
* error with a non-fatal log.warn, so the verifikat / supplier invoice was
* created but the inbox item silently stayed in "needs action" (its link
* column never landed) and the OCR document was never attached. That was the
* reported bug.
*
* The unit suites (voucher-executors.test.ts, create-supplier-invoice-from-
* inbox.test.ts) mock @/lib/supabase/server, so the CHECK constraint is never
* exercised — the buggy UPDATE "succeeds" against the mock. Only a real
* Postgres catches it. This test locks the DB-level contract the fix depends
* on: resolving an inbox row writes ONLY the link column, never `status`.
*/
// status='received', source='upload' — a fresh, unresolved inbox row, exactly
// what an uploaded item looks like before it's booked.
async function insertInboxItem(params: {
userId: string
companyId: string
documentId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_inbox_items
(id, user_id, company_id, status, source, document_id)
VALUES ($1, $2, $3, 'received', 'upload', $4)`,
[id, params.userId, params.companyId, params.documentId ?? null],
)
return id
}
// Minimal supplier + supplier_invoice so created_supplier_invoice_id has a
// valid FK target. arrival_number is UNIQUE per user; one per fresh tenant.
async function insertSupplierInvoice(params: {
userId: string
companyId: string
}): Promise<string> {
const supplierId = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Leverantör AB')`,
[supplierId, params.userId, params.companyId],
)
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number,
supplier_invoice_number, invoice_date, due_date)
VALUES ($1, $2, $3, $4, 1, 'INV-1', '2026-05-01', '2026-05-31')`,
[invoiceId, params.userId, params.companyId, supplierId],
)
return invoiceId
}
async function readInbox(
inboxId: string,
): Promise<{ status: string; created_journal_entry_id: string | null; created_supplier_invoice_id: string | null }> {
const res = await getPool().query<{
status: string
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
}>(
`SELECT status, created_journal_entry_id, created_supplier_invoice_id
FROM public.invoice_inbox_items WHERE id = $1`,
[inboxId],
)
return res.rows[0]!
}
describe('invoice_inbox_items status CHECK — root cause', () => {
it("rejects status='confirmed' (the value the old handlers wrote)", async () => {
const { userId, companyId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
await expect(
getPool().query(
`UPDATE public.invoice_inbox_items SET status = 'confirmed' WHERE id = $1`,
[inboxId],
),
).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
})
it("accepts the two legal status values, received and error", async () => {
const { userId, companyId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
await getPool().query(
`UPDATE public.invoice_inbox_items SET status = 'error' WHERE id = $1`,
[inboxId],
)
await getPool().query(
`UPDATE public.invoice_inbox_items SET status = 'received' WHERE id = $1`,
[inboxId],
)
expect((await readInbox(inboxId)).status).toBe('received')
})
})
describe('commitCreateVoucher inbox link (book-direct kvitto)', () => {
// Mirrors the WHERE clause at lib/pending-operations/commit.ts (the race
// guard: only the first commit on a still-unresolved row wins).
const WHERE = `WHERE id = $2 AND company_id = $3
AND created_journal_entry_id IS NULL
AND created_supplier_invoice_id IS NULL
RETURNING id`
it("OLD buggy form (link + status='confirmed') is rejected ATOMICALLY — link never lands", async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await expect(
getPool().query(
`UPDATE public.invoice_inbox_items
SET created_journal_entry_id = $1, status = 'confirmed' ${WHERE}`,
[jeId, inboxId, companyId],
),
).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
// The atomic rejection is the bug: the verifikat is posted, but the inbox
// row is untouched and stays in "needs action".
const row = await readInbox(inboxId)
expect(row.created_journal_entry_id).toBeNull()
expect(row.status).toBe('received')
})
it('FIXED form (link only) lands created_journal_entry_id and leaves status=received', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const res = await getPool().query(
`UPDATE public.invoice_inbox_items
SET created_journal_entry_id = $1 ${WHERE}`,
[jeId, inboxId, companyId],
)
expect(res.rows).toHaveLength(1) // one row claimed
const row = await readInbox(inboxId)
expect(row.created_journal_entry_id).toBe(jeId)
// status untouched — the link column alone drops the row out of the
// "needs action" filter (the UI and list_unmatched_documents read it).
expect(row.status).toBe('received')
})
it('race guard: a second commit on an already-linked row updates 0 rows (no clobber)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
const je1 = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const je2 = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const first = await getPool().query(
`UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 ${WHERE}`,
[je1, inboxId, companyId],
)
expect(first.rows).toHaveLength(1)
// Loser: the `created_journal_entry_id IS NULL` predicate no longer holds.
const second = await getPool().query(
`UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 ${WHERE}`,
[je2, inboxId, companyId],
)
expect(second.rows).toHaveLength(0)
expect((await readInbox(inboxId)).created_journal_entry_id).toBe(je1)
})
it('UNIQUE(created_journal_entry_id) blocks two inbox rows pointing at the same verifikat', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const inboxA = await insertInboxItem({ userId, companyId })
const inboxB = await insertInboxItem({ userId, companyId })
const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await getPool().query(
`UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 WHERE id = $2`,
[jeId, inboxA],
)
await expect(
getPool().query(
`UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 WHERE id = $2`,
[jeId, inboxB],
),
).rejects.toThrow(/unique|invoice_inbox_items_created_je/i)
})
})
describe('commitCreateSupplierInvoiceFromInbox inbox link', () => {
// Mirrors the WHERE clause at lib/pending-operations/commit.ts:1726 —
// id + company_id only; idempotency is handled by an early-return check
// upstream, so this UPDATE carries no null guards.
const WHERE = `WHERE id = $2 AND company_id = $3 RETURNING id`
it("OLD buggy form (link + status='confirmed') is rejected ATOMICALLY — link never lands", async () => {
const { userId, companyId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
const supplierInvoiceId = await insertSupplierInvoice({ userId, companyId })
await expect(
getPool().query(
`UPDATE public.invoice_inbox_items
SET created_supplier_invoice_id = $1, status = 'confirmed' ${WHERE}`,
[supplierInvoiceId, inboxId, companyId],
),
).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
const row = await readInbox(inboxId)
expect(row.created_supplier_invoice_id).toBeNull()
expect(row.status).toBe('received')
})
it('FIXED form (link only) lands created_supplier_invoice_id and leaves status=received', async () => {
const { userId, companyId } = await seedCompany()
const inboxId = await insertInboxItem({ userId, companyId })
const supplierInvoiceId = await insertSupplierInvoice({ userId, companyId })
const res = await getPool().query(
`UPDATE public.invoice_inbox_items
SET created_supplier_invoice_id = $1 ${WHERE}`,
[supplierInvoiceId, inboxId, companyId],
)
expect(res.rows).toHaveLength(1)
const row = await readInbox(inboxId)
expect(row.created_supplier_invoice_id).toBe(supplierInvoiceId)
expect(row.status).toBe('received')
})
})
@@ -313,10 +313,12 @@ describe('commitPendingOperation: create_voucher', () => {
// ── inbox-direct booking flow ──────────────────────────────────────
// gnubok_create_voucher accepts an optional inbox_item_id. On commit, the
// executor must update invoice_inbox_items (created_journal_entry_id +
// status='confirmed') and attach the OCR document to the new JE.
// executor must stamp invoice_inbox_items.created_journal_entry_id (the
// signal that drops the row out of "needs action") and attach the OCR
// document to the new JE. Status is left untouched — the status CHECK only
// allows received|error, so the link column alone marks the row processed.
it('inbox-direct: posts the entry, marks inbox confirmed, and attaches the document', async () => {
it('inbox-direct: posts the entry, links the inbox row, and attaches the document', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-inbox', voucher_number: 17, voucher_series: 'A' })
)
+31 -4
View File
@@ -1677,6 +1677,27 @@ async function commitCreateSupplierInvoiceFromInbox(
})
}
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws into
// the catch below). Without this branch the inbox item gets linked to
// an unbooked supplier invoice — the same 2440/2641 orphan the catch
// guards against. Roll back (items first, see FK note below) and return
// an actionable error instead of silently "succeeding".
await supabase
.from('supplier_invoice_items')
.delete()
.eq('supplier_invoice_id', invoice.id)
await supabase
.from('supplier_invoices')
.delete()
.eq('id', invoice.id)
.eq('company_id', companyId)
return {
error:
'Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.',
status: 400,
}
}
} catch (err) {
// Roll back: orphan supplier_invoices row without its registration JE
@@ -1719,11 +1740,14 @@ async function commitCreateSupplierInvoiceFromInbox(
}
// Terminal state for the inbox row: created_supplier_invoice_id is the
// dedup key for next time this inbox item is touched. status='confirmed'
// removes it from the "needs action" filter in the UI.
// dedup key for next time this inbox item is touched, and it's what the UI
// and list_unmatched_documents use to drop the row out of "needs action".
// Do NOT write status here — the status CHECK only allows received|error
// (migration 20260504180000); writing 'confirmed' makes Postgres reject the
// whole UPDATE, so the link column never lands and the item stays unresolved.
const { error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
.update({ created_supplier_invoice_id: invoice.id, status: 'confirmed' })
.update({ created_supplier_invoice_id: invoice.id })
.eq('id', inboxItemId)
.eq('company_id', companyId)
@@ -2301,9 +2325,12 @@ async function commitCreateVoucher(
// zero-rows-updated result and surfaces a structured warning. We also
// require .eq('created_supplier_invoice_id', null) so a concurrent
// create_supplier_invoice_from_inbox doesn't get clobbered either.
// Only the link column is written — the status CHECK allows received|error
// (migration 20260504180000), so writing 'confirmed' here would fail the
// whole UPDATE and silently leave the inbox item in "needs action".
const { data: updatedRows, error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: entry.id, status: 'confirmed' })
.update({ created_journal_entry_id: entry.id })
.eq('id', inboxItemId)
.eq('company_id', companyId)
.is('created_journal_entry_id', null)
@@ -600,8 +600,8 @@ describe('getReconciliationStatus', () => {
// 2) journal_entry_lines: 50,000 IB debit + 1000 matched debit on 1930
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } },
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
],
})
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
@@ -631,8 +631,8 @@ describe('getReconciliationStatus', () => {
// 2) GL lines: 50,000 IB + 1000 booked
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } },
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
],
})
// 3) RPC: empty
@@ -653,7 +653,7 @@ describe('getReconciliationStatus', () => {
enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] })
enqueue({
data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { source_type: 'bank_import' } }],
data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } }],
})
enqueue({ data: [] })
@@ -674,8 +674,8 @@ describe('getReconciliationStatus', () => {
enqueue({ data: [] })
enqueue({
data: [
{ debit_amount: 1000, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] },
{ debit_amount: 200, credit_amount: 0, journal_entries: [{ source_type: 'bank_import' }] },
{ debit_amount: 1000, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'opening_balance' }] },
{ debit_amount: 200, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'bank_import' }] },
],
})
enqueue({ data: [] })
@@ -685,4 +685,77 @@ describe('getReconciliationStatus', () => {
expect(status.gl_1930_opening_balance).toBe(1000)
expect(status.gl_1930_period_movement).toBe(200)
})
it('nets a book-only correction (storno + rättelse) to a reconciled period', async () => {
// The reported bug: a correction made via the storno flow puts a posted
// storno AND a posted correction on 1930, while the original flips to
// 'reversed'. Both posted vouchers have no bank-feed counterpart. Before the
// fix they inflated the period movement and showed as omatchade
// verifikationer, manufacturing a phantom diff. They must be excluded from
// the movement so a fully-matched period reconciles.
const { supabase, enqueue } = createQueueMockSupabase()
// 1) transactions: one real matched outflow of -9908.75
enqueue({
data: [{ amount: -9908.75, journal_entry_id: 'je-others', reconciliation_method: 'auto_exact' }],
})
// 2) GL lines on 1930: the matched outflow, plus the correction cluster.
// Original (credit 25000) is status='reversed'; storno (debit 25000) and
// correction (debit 25000) are posted. None of the cluster is linked.
enqueue({
data: [
{ debit_amount: 0, credit_amount: 9908.75, journal_entries: { id: 'je-others', status: 'posted', source_type: 'bank_import' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'manual' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
],
})
// 3) RPC: empty (migration excludes storno/correction; reversed isn't posted)
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
// Posted balance still includes the storno/correction (+50000) and the
// matched outflow (-9908.75); the reversed original is not posted.
expect(status.gl_1930_balance).toBe(40091.25)
// …but those +50000 are book-only and excluded from the period movement.
expect(status.gl_1930_correction_adjustment).toBe(50000)
expect(status.gl_1930_period_movement).toBe(-9908.75)
expect(status.bank_transaction_total).toBe(-9908.75)
expect(status.difference).toBe(0)
expect(status.is_reconciled).toBe(true)
})
it('does not create a new phantom when a matched deposit is later corrected', async () => {
// Case 2: a +25000 deposit was matched to an entry, then that entry was
// corrected. The deposit's link stays on the now-'reversed' original. The
// movement excludes the storno/correction, so to stay symmetric the deposit
// (linked to a reversed entry) must drop off the bank side too — otherwise
// we'd swap the old -50000 phantom for a +25000 one.
const { supabase, enqueue } = createQueueMockSupabase()
// 1) transactions: the +25000 deposit, still linked to the reversed original
enqueue({
data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
})
// 2) GL lines: reversed original (debit 25000), storno (credit 25000),
// correction (debit 25000)
enqueue({
data: [
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
],
})
// 3) RPC: empty
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
// Deposit excluded (linked to a reversed entry); cluster excluded from movement.
expect(status.bank_transaction_total).toBe(0)
expect(status.gl_1930_period_movement).toBe(0)
expect(status.difference).toBe(0)
expect(status.is_reconciled).toBe(true)
})
})
+62 -32
View File
@@ -45,11 +45,17 @@ export interface ReconciliationStatus {
* is computed against `gl_1930_period_movement`, not this.
*/
gl_1930_balance: number
/** Ledger movement on 1930 excluding source_type='opening_balance' lines. */
/** Ledger movement on 1930 excluding opening_balance AND storno/correction
* lines — i.e. only movements that have a bank-feed counterpart. */
gl_1930_period_movement: number
/** IB on 1930 within the date range — surfaced separately so reconciliation
* doesn't treat it as an unmatched bank transaction. */
gl_1930_opening_balance: number
/** Net of posted storno/correction lines on 1930 within the date range.
* Excluded from gl_1930_period_movement (and from the unmatched-voucher set)
* because a book-only correction has no counterpart in the bank feed. Surfaced
* separately so the UI can explain why a corrected period still reconciles. */
gl_1930_correction_adjustment: number
/** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */
difference: number
is_reconciled: boolean
@@ -278,54 +284,77 @@ export async function getReconciliationStatus(
const { data: transactions } = await txQuery
// Get GL bank account lines (all, not just unlinked). Pull source_type
// from the join so we can split IB out of the period-movement comparison —
// an opening_balance line on 1930 is the prior year's closing balance, not
// a bank transaction we should expect to match.
// Get GL bank account lines. Pull id/status/source_type from the join so we
// can (a) split out lines that have no bank-feed counterpart — opening_balance
// (prior year's closing balance) and storno/correction (book-only corrections)
// — and (b) identify reversed originals, whose still-linked bank transactions
// are superseded by the correction and must drop off the bank side too.
// 'reversed' is fetched alongside 'posted' precisely to resolve those links;
// reversed lines are NOT counted in any movement total.
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status, source_type)')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
const { data: glLines } = await glQuery
type GlEntry = { id?: string | null; status?: string | null; source_type?: string | null }
type GlLineRow = {
debit_amount: number | string | null
credit_amount: number | string | null
journal_entries: { source_type?: string | null } | { source_type?: string | null }[] | null
journal_entries: GlEntry | GlEntry[] | null
}
function isOpeningBalance(line: GlLineRow): boolean {
// Supabase typings sometimes widen embedded relations to arrays even when the
// join is one-to-one. Handle both shapes defensively.
function entryOf(line: GlLineRow): GlEntry | null {
const je = line.journal_entries
if (!je) return false
// Supabase typings sometimes widen embedded relations to arrays even when
// the join is one-to-one. Handle both shapes defensively.
const sourceType = Array.isArray(je) ? je[0]?.source_type : je.source_type
return sourceType === 'opening_balance'
if (!je) return null
return Array.isArray(je) ? je[0] ?? null : je
}
function lineAmount(line: GlLineRow): number {
return (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
// Calculate totals
const bankTotal = (transactions || []).reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
0
)
const allLines = (glLines || []) as GlLineRow[]
const glBalance = allLines.reduce(
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
0
const postedLines = allLines.filter((l) => entryOf(l)?.status === 'posted')
// Reversed originals retain their bank-transaction link (the storno flow never
// re-points it), so a transaction pointing at one is a superseded booking —
// drop it from the bank side to keep the comparison symmetric with the
// movement, which excludes the matching storno/correction below.
const reversedEntryIds = new Set<string>(
allLines
.filter((l) => entryOf(l)?.status === 'reversed')
.map((l) => entryOf(l)?.id)
.filter((id): id is string => Boolean(id))
)
const glOpeningBalance = allLines
.filter(isOpeningBalance)
.reduce(
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
0
)
const glPeriodMovement = glBalance - glOpeningBalance
// Calculate totals. Exclude transactions whose linked entry was reversed —
// their booking lives on in the correction, which is itself excluded from the
// movement, so counting the transaction would resurrect a phantom diff.
const bankTotal = (transactions || []).reduce((sum, tx) => {
if (tx.journal_entry_id && reversedEntryIds.has(tx.journal_entry_id)) return sum
return sum + (Number(tx.amount) || 0)
}, 0)
// gl_1930_balance keeps its historical meaning: the posted balance incl. IB.
const glBalance = postedLines.reduce((sum, line) => sum + lineAmount(line), 0)
const glOpeningBalance = postedLines
.filter((l) => entryOf(l)?.source_type === 'opening_balance')
.reduce((sum, line) => sum + lineAmount(line), 0)
const glCorrectionAdjustment = postedLines
.filter((l) => {
const st = entryOf(l)?.source_type
return st === 'storno' || st === 'correction'
})
.reduce((sum, line) => sum + lineAmount(line), 0)
// Period movement = only the lines that have a bank-feed counterpart.
const glPeriodMovement = glBalance - glOpeningBalance - glCorrectionAdjustment
const matchedCount = (transactions || []).filter(
(tx) => tx.journal_entry_id !== null
@@ -335,8 +364,8 @@ export async function getReconciliationStatus(
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
// 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql)
// Unlinked GL lines count (RPC excludes opening_balance, storno and correction
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql)
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
@@ -346,6 +375,7 @@ export async function getReconciliationStatus(
gl_1930_balance: Math.round(glBalance * 100) / 100,
gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100,
gl_1930_opening_balance: Math.round(glOpeningBalance * 100) / 100,
gl_1930_correction_adjustment: Math.round(glCorrectionAdjustment * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
matched_count: matchedCount,
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
amountToOre,
buildStableExternalIds,
contentDedupKey,
normalizeImportedDescription,
FALLBACK_DESCRIPTION,
} from '../external-id'
describe('amountToOre', () => {
it('normalizes a JS number to integer öre', () => {
expect(amountToOre(1234.5)).toBe(123450)
expect(amountToOre(-250)).toBe(-25000)
expect(amountToOre(0)).toBe(0)
})
it('normalizes a numeric string (PostgREST representation) to the same öre', () => {
// The core fix: a DB-fetched numeric string and a raw JS number for the
// same amount must collapse to the same integer.
expect(amountToOre('1234.50')).toBe(123450)
expect(amountToOre('1234.5')).toBe(amountToOre(1234.5))
expect(amountToOre('-250.00')).toBe(amountToOre(-250))
expect(amountToOre('100')).toBe(10000)
})
it('rounds sub-öre noise deterministically (never toFixed)', () => {
expect(amountToOre(0.1 + 0.2)).toBe(30) // 0.30000000000000004 → 30
expect(amountToOre(19.995)).toBe(2000)
})
})
describe('buildStableExternalIds', () => {
it('derives the id from account + date + öre, not from any bank id', () => {
const ids = buildStableExternalIds('eb', 'SE123', [{ date: '2024-06-15', amount: -500 }])
expect(ids).toEqual(['eb_SE123_2024-06-15_-50000_0'])
})
it('disambiguates genuinely identical transactions with an occurrence index', () => {
const ids = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
])
expect(ids).toEqual([
'eb_acc_2024-06-15_-25000_0',
'eb_acc_2024-06-15_-25000_1',
'eb_acc_2024-06-15_-25000_2',
])
})
it('produces the SAME set of ids regardless of provider ordering (re-sync dedupe)', () => {
const a = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-16', amount: -100 },
{ date: '2024-06-15', amount: -250 },
])
// Same transactions, different order on a later sync.
const b = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-16', amount: -100 },
])
expect(new Set(a)).toEqual(new Set(b))
})
it('treats string and number amounts as the same id (provider type drift)', () => {
const num = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: 1234.5 }])
const str = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: '1234.50' }])
expect(num).toEqual(str)
})
it('keeps distinct amounts and dates on separate occurrence counters', () => {
const ids = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-16', amount: -250 },
{ date: '2024-06-15', amount: -250 },
])
expect(ids).toEqual([
'eb_acc_2024-06-15_-25000_0',
'eb_acc_2024-06-15_-10000_0',
'eb_acc_2024-06-16_-25000_0',
'eb_acc_2024-06-15_-25000_1',
])
})
it('returns an empty array for an empty batch', () => {
expect(buildStableExternalIds('eb', 'acc', [])).toEqual([])
})
})
describe('contentDedupKey', () => {
it('matches a JS number against a PostgREST numeric string for the same amount', () => {
// The core dedup-bridge fix: an incoming raw number and a DB-fetched string
// for the same amount + date + description must produce the SAME key.
const incoming = contentDedupKey('2024-06-15', -250, 'ICA Maxi Solna')
const stored = contentDedupKey('2024-06-15', '-250.00', 'ICA Maxi Solna')
expect(incoming).toBe(stored)
})
it('normalizes description (lowercase, trim, 24-char prefix)', () => {
expect(contentDedupKey('2024-06-15', -100, ' ICA Maxi Solna '))
.toBe(contentDedupKey('2024-06-15', -100, 'ica maxi solna'))
// Differs only past the 24-char prefix → same key.
expect(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör AAA'))
.toBe(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör BBB'))
})
it('keeps distinct transactions apart when description differs in the prefix', () => {
expect(contentDedupKey('2024-06-15', -250, 'ICA Maxi'))
.not.toBe(contentDedupKey('2024-06-15', -250, 'Coop Stockholm'))
})
it('treats a null/undefined description as an empty prefix', () => {
expect(contentDedupKey('2024-06-15', -100, null))
.toBe(contentDedupKey('2024-06-15', -100, undefined))
expect(contentDedupKey('2024-06-15', -100, null))
.toBe(contentDedupKey('2024-06-15', -100, ''))
})
})
describe('normalizeImportedDescription', () => {
it('maps empty / whitespace-only titles to the Swedish neutral', () => {
expect(normalizeImportedDescription('')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(' ')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(null)).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(undefined)).toBe(FALLBACK_DESCRIPTION)
})
it('maps the legacy English "Unknown" sentinel to the Swedish neutral (case-insensitive)', () => {
expect(normalizeImportedDescription('Unknown')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription('unknown')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(' UNKNOWN ')).toBe(FALLBACK_DESCRIPTION)
})
it('preserves a real title and trims surrounding whitespace', () => {
expect(normalizeImportedDescription('ICA Maxi Solna')).toBe('ICA Maxi Solna')
expect(normalizeImportedDescription(' Lön juni ')).toBe('Lön juni')
})
it('does NOT clobber a real title that merely contains the word "unknown"', () => {
expect(normalizeImportedDescription('Unknown Pizza AB')).toBe('Unknown Pizza AB')
})
})
+145
View File
@@ -211,6 +211,85 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2b-edit. Edit-safety regression: a user-edited stored title must NOT
// reopen the duplicate-import window. The content bridge keys off the
// immutable original_description, so a re-import whose bank text still
// matches the original is deduped even though the stored (editable)
// description was changed.
// -----------------------------------------------------------------------
it('dedupes against the original bank description even after the stored title was edited', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna', // original bank text, re-imported via CSV
external_id: 'lunar_csvhash999', // different external_id → primary dedup misses
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced row whose TITLE was edited by the user, but whose
// original_description still holds the bank's verbatim text.
enqueue({
data: [
{
date: '2024-06-15',
amount: -250.0,
original_description: 'ICA Maxi Solna',
description: 'Mataffär (egen rubrik)',
},
],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2b-unknown. Legacy 'Unknown'/empty rows must still dedup: the stored-side
// content key is normalized the same way as the incoming side, so an
// existing row whose original_description is the legacy 'Unknown'
// sentinel matches an incoming 'Unknown' re-import (both → 'Okänd
// transaktion'). Without symmetric normalization this row would
// re-import as a duplicate.
// -----------------------------------------------------------------------
it('dedupes legacy "Unknown" rows by normalizing both the stored and incoming keys', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'Unknown', // legacy English sentinel re-imported via CSV
external_id: 'lunar_csvhashU',
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced row whose original_description is the legacy sentinel.
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, original_description: 'Unknown', description: 'Unknown' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2c. No false positive: same date+amount but different description does
// NOT trigger content dedup — guards against the historical concern
@@ -791,6 +870,72 @@ describe('ingestTransactions', () => {
expect(result.duplicates).toBe(0)
})
it('bridges the external_id scheme change: a booked OLD-scheme eb_ row is caught by content dedup on re-sync', async () => {
// Transition scenario: an enable_banking row was imported+booked under the
// OLD unstable scheme (eb_{iban}_{txid}). After deploy, the re-sync derives
// a NEW content-based external_id that will NOT match by external_id, so
// layer-1 misses. Layer 1b (booked content dedup) MUST catch it, otherwise
// the user sees the exact duplicate the fix is meant to prevent.
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'eb_SE123_2024-06-15_-25000_0', // new scheme
date: '2024-06-15',
amount: -250,
description: 'ICA Maxi Solna',
import_source: 'enable_banking',
})
// Booked map: the SAME transaction still carries its OLD-scheme external_id
// in the DB; dedup matches on content, not on external_id.
enqueue({
data: [{ date: '2024-06-15', amount: -250, description: 'ICA Maxi Solna' }],
error: null,
})
// Unbooked enable_banking map — none
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — DB still holds eb_SE123_{old_txid}, so the
// new external_id finds NO match here.
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('dedupes against a booked row whose amount is a numeric string (PostgREST), not a number', async () => {
// Regression: PostgREST can serialize a `numeric` column as a string
// ("-250.00") while the incoming raw amount is a JS number (-250). Before
// the öre-normalized dedup key these never compared equal, so content dedup
// silently missed and the row was re-imported as a duplicate.
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'eb_acc_2024-06-15_-25000_0',
date: '2024-06-15',
amount: -250,
description: 'ICA Maxi Solna',
})
// Booked map: same transaction, amount as a STRING with trailing zeros.
enqueue({
data: [{ date: '2024-06-15', amount: '-250.00', description: 'ICA Maxi Solna' }],
error: null,
})
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no match by external_id — the id scheme changed)
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('handles multiple booked transactions with same date+amount correctly', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
+139
View File
@@ -0,0 +1,139 @@
/**
* Shared helpers for deriving stable bank-transaction `external_id`s and for
* normalizing monetary amounts used in dedup keys.
*
* Why this exists
* ---------------
* The transactions table is deduplicated on `(company_id, external_id)` (a
* partial unique index, see migration 20260330130000). The dedup is therefore
* only as good as the stability of `external_id` across re-syncs.
*
* For Enable Banking (PSD2 / Berlin Group) the previous scheme keyed
* `external_id` off the bank's `entry_reference` / `transaction_id`
* (`eb_{account}_{tx.id}`). Many Swedish ASPSPs do NOT return those fields
* stably across requests — a later "synka nu" can return the same underlying
* transaction with a different id, which produced a *new* `external_id` and
* therefore a duplicate row (including for transactions the user had already
* booked). See `buildStableExternalIds` for the content-derived replacement.
*/
/**
* Normalize a monetary amount to integer öre (hundredths) for stable,
* representation-agnostic comparison.
*
* PostgREST may return a `numeric` column as a JS number OR as a string
* (preserving precision), so `1234.5` and the string `"1234.50"` can describe
* the same amount. Interpolating either directly into a dedup key yields
* different strings (`"1234.5"` vs `"1234.50"`), silently breaking content
* dedup. Rounding to integer öre collapses both to `123450`.
*
* Uses the project-standard `Math.round(x * 100)` (never `toFixed`).
*/
export function amountToOre(amount: number | string): number {
return Math.round(Number(amount) * 100)
}
/**
* Swedish-first placeholder for transactions a bank/import source gives no
* usable title. Centralized so every import path and the tests agree.
*/
export const FALLBACK_DESCRIPTION = 'Okänd transaktion'
/**
* Normalize an imported transaction title for storage and display.
*
* Maps both an empty/whitespace title AND the legacy English 'Unknown'
* sentinel — still emitted by the bank-file format parsers and as the Enable
* Banking converter's last resort — to a Swedish-first neutral. Applied once at
* the ingest boundary so every source (PSD2 sync + CSV/CAMT import) inherits
* it; the bank's verbatim text is preserved separately in
* `transactions.original_description`. Match on the exact 'unknown' sentinel
* (case-insensitive) so a real description that merely contains the word is
* never clobbered.
*/
export function normalizeImportedDescription(raw: string | null | undefined): string {
const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed.toLowerCase() === 'unknown') return FALLBACK_DESCRIPTION
return trimmed
}
/**
* Build stable, collision-safe `external_id`s for a batch of bank transactions
* whose provider does not supply a reliable stable id (e.g. Enable Banking).
*
* The id is derived from content — `{prefix}_{accountScope}_{date}_{öre}_{n}`
* — where `n` is an occurrence index that disambiguates genuinely identical
* transactions (same account, date and amount) within the batch.
*
* Properties this guarantees:
* - **Re-sync dedupe**: the same set of transactions produces the same *set*
* of ids regardless of the order the ASPSP returns them in, so a repeat sync
* collides with the existing rows on `(company_id, external_id)` and is
* skipped — even after the user has booked them. (The id *set* is what the
* unique index enforces; which physical row maps to `..._0` vs `..._1` need
* not be stable, only the set.)
* - **No false dedupe**: two legitimately distinct transactions that share a
* date and amount get different ids (`..._0`, `..._1`) and are both kept.
* This is the safeguard the bank-file importer already relies on via its
* `rowIndex` component (see `lib/import/bank-file/parser.ts`).
*
* Why description is NOT an input here (but IS in `contentDedupKey`): the
* `external_id` must be a *stable unique key*, so it cannot depend on a field
* that drifts — PSD2 enriches/reorders descriptions between a transaction's
* pending and booked states. The occurrence index gives uniqueness without
* that fragility. `contentDedupKey` has the opposite job — it is a best-effort
* *bridge* that must avoid dropping real transactions — so it keeps the
* description (see that function for the trade-off).
*
* @param prefix Source tag, e.g. `'eb'` for Enable Banking.
* @param accountScope Stable per-account scope (prefer IBAN, fall back to the
* provider account uid). Keeps ids unique across accounts.
* Callers should pass a whitespace/case-normalized IBAN so
* formatting variants ("SE45 5000…" vs "SE455000…") don't
* produce different ids for the same account.
* @param txns Batch in provider order; each needs `date` + `amount`.
*/
export function buildStableExternalIds(
prefix: string,
accountScope: string,
txns: Array<{ date: string; amount: number | string }>
): string[] {
const occurrences = new Map<string, number>()
return txns.map((tx) => {
const fingerprint = `${tx.date}_${amountToOre(tx.amount)}`
const n = occurrences.get(fingerprint) ?? 0
occurrences.set(fingerprint, n + 1)
return `${prefix}_${accountScope}_${fingerprint}_${n}`
})
}
/**
* Stable content-dedup key used to bridge transactions across `external_id`
* schemes and import sources (PSD2 ⇄ CSV, old id scheme ⇄ new id scheme).
*
* Format: `{date}|{öre}|{normalized description prefix}`.
*
* This is a *best-effort* dedup signal, not a unique key. It is consumed with
* COUNTING semantics in the ingest pipeline (N existing matches consume N
* incoming), and its job is to skip re-imports WITHOUT ever dropping a real
* transaction. That asymmetry drives the two design choices here:
*
* - **öre via `amountToOre`** — a JS number (`-250`) and a PostgREST numeric
* string (`"-250.00"`) must collapse to the same key, otherwise dedup
* silently misses.
* - **description IS included** (unlike `external_id`) — two genuinely distinct
* transactions that merely share a date and amount (e.g. two SEK 250 card
* purchases) must NOT be collapsed into one, or a real transaction is lost.
* Including the description prefix biases toward keeping both. The cost is
* that if a description drifts between syncs the bridge can miss a true
* duplicate — an acceptable trade for an accounting ledger, where a visible,
* user-deletable duplicate is far safer than a silently dropped row.
*/
export function contentDedupKey(
date: string,
amount: number | string,
description: string | null | undefined
): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amountToOre(amount)}|${descPrefix}`
}
+35 -19
View File
@@ -7,6 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { contentDedupKey, normalizeImportedDescription } from '@/lib/transactions/external-id'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
// Re-export types for backward compatibility
@@ -25,18 +26,6 @@ interface ExistingTransactionMaps {
unbookedEnableBanking: Map<string, number>
}
/**
* Stable content-dedup key. Includes a normalized description prefix so the
* two-tuple (date, amount) doesn't false-positive across unrelated transfers
* that happen to share a date and amount. Lunar's CSV "Text" column and
* PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
* agree well enough in practice for the same underlying transaction.
*/
function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amount}|${descPrefix}`
}
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
@@ -53,7 +42,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount, description')
.select('date, amount, original_description, description')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -61,7 +50,15 @@ async function buildExistingTransactionMaps(
if (bookedRows) {
for (const tx of bookedRows) {
const key = contentDedupKey(tx.date, tx.amount, tx.description)
// Key off the immutable bank original, not the user-editable
// description: a title edit must never make the dedup bridge miss a
// genuine re-import. Falls back to description for rows predating the
// original_description column.
const key = contentDedupKey(
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
@@ -72,7 +69,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount, description')
.select('date, amount, original_description, description')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -81,7 +78,13 @@ async function buildExistingTransactionMaps(
if (unbookedBank) {
for (const tx of unbookedBank) {
const key = contentDedupKey(tx.date, tx.amount, tx.description)
// See booked-map note: dedup on the immutable bank original so a
// user title edit cannot reopen the duplicate-import window.
const key = contentDedupKey(
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
)
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
@@ -216,6 +219,14 @@ export async function ingestTransactions(
const matchedSupplierInvoiceIds = new Set<string>()
for (const raw of rawTransactions) {
// Normalize the source title once. Guarantees a non-empty, Swedish-first
// label for every import path (PSD2 sync + all bank-file CSV/CAMT parsers
// funnel into raw.description) — catching both empty/whitespace titles and
// the legacy English 'Unknown' sentinel. This normalized value is stored as
// both description and original_description below; it's what the user sees
// and edits, and what the content-dedup key is built from.
const description = normalizeImportedDescription(raw.description)
// 1. Check for duplicates via external_id (batch pre-fetched)
if (existingExternalIds.has(raw.external_id)) {
result.duplicates++
@@ -223,8 +234,9 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date, amount, and description prefix.
const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
// exists with the same date, amount, and description prefix. Built from the
// normalized description so it matches the stored original_description keys.
const contentKey = contentDedupKey(raw.date, raw.amount, description)
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
existingMaps.booked.set(contentKey, bookedCount - 1)
@@ -260,7 +272,11 @@ export async function ingestTransactions(
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
date: raw.date,
description: raw.description,
description: description,
// Immutable bank/PSD2 original — captured once, never overwritten by a
// title edit. Equals description at insert; they diverge only if the
// user later edits the title.
original_description: description,
amount: raw.amount,
currency: raw.currency,
amount_sek: amountSek,