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
@@ -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,