fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)
* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes, regrouped by what the user is doing. CLAUDE.md restructured around Hard Rules (doc references updated); pending-page explainer removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0) Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row cap corrupted totals), optimistic-lock guards on manualLink + apply, unlink audit rows attributed to the acting user (was: company UUID), selected_matches partial apply intersected with a fresh match run. View: silent in-place refresh instead of a full-page skeleton per action, checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of 500, honest result toasts, dry-run errors surfaced, ranked per-row picker candidates pinned to the applied date window, currency-correct amounts (bank side in account currency, GL side SEK), voucher links, translated source types, colored differens, dirty-date-filter guard. Discovery: year-end preflight 404 href fixed (/reconciliation/bank never existed), ⌘K palette entry, real links from the transactions page. v1: status registry schema now matches the actual ReconciliationStatus payload, errors documented as a count, false ~0.85-threshold pitfall replaced, route test mocks the real shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
59ecaee650
commit
ea236cbcdf
@@ -1421,6 +1421,18 @@ export const RunReconciliationSchema = z.object({
|
||||
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
|
||||
.optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
// Pairs the user ticked in the dry-run preview. When present on an apply
|
||||
// (dry_run false), only these pairs are committed — intersected server-side
|
||||
// with a fresh match run, so a stale or fabricated pair is never applied.
|
||||
selected_matches: z
|
||||
.array(
|
||||
z.object({
|
||||
transaction_id: uuid,
|
||||
journal_entry_id: uuid,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -126,7 +126,10 @@ export async function buildBokslutReadinessReport(
|
||||
reconciliation.unmatched_transaction_count > 0
|
||||
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
|
||||
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
|
||||
href: '/reconciliation/bank',
|
||||
// Bankavstämning's real route — the earlier '/reconciliation/bank' href
|
||||
// pointed at a page that has never existed, so the wizard's "Öppna"
|
||||
// link 404ed.
|
||||
href: '/reports/bank-reconciliation',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function enqueueManualLinkSuccess(
|
||||
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
|
||||
enqueue({ data: null, error: null }) // update
|
||||
enqueue({ data: [{ id: 'tx-1' }] }) // update — .select('id') returns the updated row
|
||||
}
|
||||
|
||||
describe('autoReconcileTransactionForLinkedVoucher', () => {
|
||||
|
||||
@@ -445,8 +445,8 @@ describe('runReconciliation', () => {
|
||||
enqueue({ data: [glLine] })
|
||||
// from('transactions') returns unmatched transactions
|
||||
enqueue({ data: [tx] })
|
||||
// Update transaction with link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction with link — .select('id') returns the updated row
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
|
||||
|
||||
@@ -454,6 +454,90 @@ describe('runReconciliation', () => {
|
||||
expect(result.applied).toBe(1)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
||||
const glLine: UnlinkedGLLine = makeGLLine({
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
credit_amount: 500,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
|
||||
enqueue({ data: [glLine] })
|
||||
enqueue({ data: [tx] })
|
||||
// Optimistic-lock guard: a concurrent linker got there first — the
|
||||
// .is('journal_entry_id', null) filter matches zero rows.
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
|
||||
|
||||
expect(result.applied).toBe(0)
|
||||
expect(result.errors).toBe(1)
|
||||
})
|
||||
|
||||
it('applies only the pairs in applyOnly, intersected with the fresh match run', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const tx2 = makeTransaction({ id: 'tx-2', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
||||
const line1 = makeGLLine({
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
debit_amount: 1000,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
const line2 = makeGLLine({
|
||||
line_id: 'line-2',
|
||||
journal_entry_id: 'je-2',
|
||||
credit_amount: 500,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
|
||||
enqueue({ data: [line1, line2] })
|
||||
enqueue({ data: [tx1, tx2] })
|
||||
// Only ONE update should run — for the single selected pair.
|
||||
enqueue({ data: [{ id: 'tx-2' }] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
||||
dryRun: false,
|
||||
applyOnly: [
|
||||
{ transactionId: 'tx-2', journalEntryId: 'je-2' },
|
||||
// A pair the matcher never proposed must be ignored, not applied.
|
||||
{ transactionId: 'tx-99', journalEntryId: 'je-99' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches[0].transaction.id).toBe('tx-2')
|
||||
expect(result.applied).toBe(1)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores applyOnly on dry runs and returns the full match set', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
||||
const line1 = makeGLLine({
|
||||
line_id: 'line-1',
|
||||
journal_entry_id: 'je-1',
|
||||
debit_amount: 1000,
|
||||
entry_date: '2024-06-15',
|
||||
})
|
||||
|
||||
enqueue({ data: [line1] })
|
||||
enqueue({ data: [tx1] })
|
||||
|
||||
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
||||
dryRun: true,
|
||||
applyOnly: [],
|
||||
})
|
||||
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.applied).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -569,14 +653,31 @@ describe('manualLink', () => {
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Line exists on the selected account
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds — .select('id') returns the updated row
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when a concurrent linker won the race (0 rows updated)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// The .is('journal_entry_id', null) optimistic-lock filter matched nothing:
|
||||
// another session linked the transaction between our read and this write.
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
||||
})
|
||||
|
||||
it('succeeds for a bound transaction when the account matches', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({
|
||||
@@ -591,8 +692,8 @@ describe('manualLink', () => {
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
// Line exists on 1930
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds — .select('id') returns the updated row
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
@@ -612,7 +713,7 @@ describe('manualLink', () => {
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// Update succeeds — note there is NO existing-link lookup in the sequence.
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'tx-2' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-2', 'je-1', 'user-1', '1930')
|
||||
|
||||
@@ -672,7 +773,7 @@ describe('unlinkReconciliation', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
|
||||
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Cannot unlink')
|
||||
@@ -692,10 +793,52 @@ describe('unlinkReconciliation', () => {
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
|
||||
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('attributes the audit log row to the acting user, not the company', async () => {
|
||||
// Regression: unlinkReconciliation used to pass companyId where
|
||||
// logMatchEvent expects userId, so payment_match_log.user_id recorded the
|
||||
// company UUID (or the insert failed its FK silently).
|
||||
const inserts: Record<string, unknown>[] = []
|
||||
const resultQueue: { data: unknown; error: unknown }[] = [
|
||||
{
|
||||
data: { id: 'tx-1', journal_entry_id: 'je-1', reconciliation_method: 'manual' },
|
||||
error: null,
|
||||
},
|
||||
{ data: null, error: null }, // update
|
||||
]
|
||||
const buildChain = (table?: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
if (prop === 'insert') {
|
||||
return (row: Record<string, unknown>) => {
|
||||
if (table === 'payment_match_log') inserts.push(row)
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(inserts).toHaveLength(1)
|
||||
expect(inserts[0].user_id).toBe('user-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction, ReconciliationMethod } from '@/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -102,6 +103,15 @@ export interface ReconciliationOptions {
|
||||
* currency-only callers (where cashAccountId is omitted and this is moot).
|
||||
*/
|
||||
includeUnassigned?: boolean
|
||||
/**
|
||||
* Apply only these transaction↔journal-entry pairs (ignored on dry runs).
|
||||
* The UI's dry-run preview lets the user untick suspicious matches; a
|
||||
* subsequent apply passes the ticked pairs here so the server never commits
|
||||
* a match the user excluded — and never commits a pair the matcher itself
|
||||
* didn't propose on the re-run, since the filter intersects with the fresh
|
||||
* match set rather than trusting the client's pairs blindly.
|
||||
*/
|
||||
applyOnly?: Array<{ transactionId: string; journalEntryId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,43 +261,62 @@ export async function runReconciliation(
|
||||
currency = 'SEK',
|
||||
cashAccountId,
|
||||
includeUnassigned = true,
|
||||
applyOnly,
|
||||
} = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions, scoped to the selected cash account.
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
|
||||
// Paginated: a busy company can exceed PostgREST's silent 1000-row cap, which
|
||||
// would make the matcher skip transactions without any signal. Ordered on id
|
||||
// (unique) so pages never duplicate or skip rows.
|
||||
const transactions = await fetchAllRows<Transaction>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
return query.order('id').range(from, to)
|
||||
})
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
|
||||
const { data: transactions } = await query
|
||||
|
||||
if (!transactions || transactions.length === 0 || glLines.length === 0) {
|
||||
if (transactions.length === 0 || glLines.length === 0) {
|
||||
return { matches: [], applied: 0, errors: 0 }
|
||||
}
|
||||
|
||||
// Run greedy matching, highest confidence first
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines, currency)
|
||||
let matches = greedyMatch(transactions, glLines, currency)
|
||||
|
||||
if (dryRun) {
|
||||
return { matches, applied: 0, errors: 0 }
|
||||
}
|
||||
|
||||
// When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs
|
||||
// that BOTH the user selected AND the fresh match run still proposes — the
|
||||
// intersection guards against data that changed between preview and apply.
|
||||
if (applyOnly) {
|
||||
const selected = new Set(applyOnly.map((p) => `${p.transactionId}:${p.journalEntryId}`))
|
||||
matches = matches.filter((m) =>
|
||||
selected.has(`${m.transaction.id}:${m.glLine.journal_entry_id}`),
|
||||
)
|
||||
}
|
||||
|
||||
// Apply matches
|
||||
let applied = 0
|
||||
let errors = 0
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
// .is('journal_entry_id', null) is an optimistic-lock guard: if a
|
||||
// concurrent user (or another surface) linked this transaction between
|
||||
// the read above and this write, the update matches zero rows instead of
|
||||
// silently re-pointing an existing link. Same pattern as
|
||||
// lib/transactions/link-journal-entry.ts.
|
||||
const { data: updatedRows, error } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: match.glLine.journal_entry_id,
|
||||
@@ -296,8 +325,10 @@ export async function runReconciliation(
|
||||
})
|
||||
.eq('id', match.transaction.id)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
if (error || !updatedRows || updatedRows.length === 0) {
|
||||
errors++
|
||||
} else {
|
||||
applied++
|
||||
@@ -353,16 +384,27 @@ export async function getReconciliationStatus(
|
||||
// user has explicitly said they don't want them surfacing as something to
|
||||
// reconcile. Scoping by cash account (not just currency) is what stops a
|
||||
// second same-currency account from inflating bankTotal here.
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.eq('company_id', companyId)
|
||||
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
|
||||
const { data: transactions } = await txQuery
|
||||
// Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at 1000
|
||||
// rows, which would undercount bank_transaction_total for a busy company and
|
||||
// manufacture a phantom, unexplainable difference. Ordered on id (unique) so
|
||||
// pages never duplicate or skip rows across boundaries.
|
||||
type StatusTxRow = {
|
||||
date: string | null
|
||||
amount: number | string | null
|
||||
journal_entry_id: string | null
|
||||
reconciliation_method: string | null
|
||||
is_ignored: boolean | null
|
||||
}
|
||||
const transactions = await fetchAllRows<StatusTxRow>(({ from, to }) => {
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.eq('company_id', companyId)
|
||||
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
return txQuery.order('id').range(from, to)
|
||||
})
|
||||
|
||||
// Get GL bank-account lines. We fetch posted AND reversed entries and count
|
||||
// them TOGETHER — the exact inclusion rule the trial balance and balance sheet
|
||||
@@ -374,18 +416,6 @@ export async function getReconciliationStatus(
|
||||
// the headline bug this widget had (a corrected bank receipt showed one figure
|
||||
// here and a different one on the balance sheet). source_type is still pulled
|
||||
// so we can split out the opening balance and surface correction activity.
|
||||
let glQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.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)
|
||||
.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
|
||||
@@ -410,7 +440,19 @@ export async function getReconciliationStatus(
|
||||
|
||||
// posted + reversed = the ledger balance, exactly as the trial balance counts
|
||||
// it. The .in() filter on the query already excludes draft/cancelled.
|
||||
const fetchedLines = (glLines || []) as GlLineRow[]
|
||||
// Paginated for the same 1000-row-cap reason as the transactions above — a
|
||||
// silently truncated GL side would corrupt gl_1930_balance and the difference.
|
||||
const fetchedLines = await fetchAllRows<GlLineRow>(({ from, to }) => {
|
||||
let glQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.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)
|
||||
.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)
|
||||
return glQuery.order('id').range(from, to)
|
||||
})
|
||||
|
||||
// Floor the window at the most recent opening-balance date on this account
|
||||
// (issue #751). Everything dated before that IB is prior history the IB entry
|
||||
@@ -597,8 +639,11 @@ export async function manualLink(
|
||||
// already-matched voucher when the user opts in via "Visa även matchade
|
||||
// verifikationer", so this can't happen by accident.
|
||||
|
||||
// Apply link
|
||||
const { error: updateError } = await supabase
|
||||
// Apply link. The .is('journal_entry_id', null) guard re-checks the "not
|
||||
// already linked" precondition inside the write itself — the read above is
|
||||
// advisory, and two concurrent linkers would otherwise silently re-point the
|
||||
// row (same optimistic-lock pattern as lib/transactions/link-journal-entry.ts).
|
||||
const { data: updatedRows, error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntryId,
|
||||
@@ -607,10 +652,15 @@ export async function manualLink(
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
|
||||
}
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
|
||||
}
|
||||
|
||||
try {
|
||||
eventBus.emit({
|
||||
@@ -637,7 +687,8 @@ export async function manualLink(
|
||||
export async function unlinkReconciliation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transactionId: string
|
||||
transactionId: string,
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Fetch transaction
|
||||
const { data: tx, error: txError } = await supabase
|
||||
@@ -673,7 +724,7 @@ export async function unlinkReconciliation(
|
||||
return { success: false, error: 'Failed to unlink transaction' }
|
||||
}
|
||||
|
||||
logMatchEvent(supabase, companyId, transactionId, 'unmatched', {
|
||||
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
|
||||
previousState: {
|
||||
journal_entry_id: tx.journal_entry_id,
|
||||
reconciliation_method: tx.reconciliation_method,
|
||||
@@ -900,15 +951,30 @@ export async function fetchUnlinkedGLLines(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
): Promise<UnlinkedGLLine[]> {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
})
|
||||
|
||||
if (error || !data) return []
|
||||
return data as UnlinkedGLLine[]
|
||||
// Paginated: the RPC returns SETOF and is subject to the same silent
|
||||
// 1000-row PostgREST cap as table selects; truncation here would hide match
|
||||
// candidates and undercount unmatched_gl_line_count. The .order() chain
|
||||
// preserves the RPC's chronological order for consumers (the UI table, the
|
||||
// picker) while the unique line_id tiebreaker keeps pages stable — several
|
||||
// lines of one entry share entry_date/voucher_number. Errors keep the legacy
|
||||
// contract: callers get [] rather than a throw.
|
||||
try {
|
||||
return await fetchAllRows<UnlinkedGLLine>(({ from, to }) =>
|
||||
supabase
|
||||
.rpc('get_unlinked_gl_lines', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
})
|
||||
.order('entry_date')
|
||||
.order('voucher_number')
|
||||
.order('line_id')
|
||||
.range(from, to),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** A match candidate that carries how many transactions already point at it. */
|
||||
@@ -933,17 +999,29 @@ export async function fetchGLLinesForMatching(
|
||||
dateTo?: string,
|
||||
includeMatched: boolean = false,
|
||||
): Promise<GLLineForMatching[]> {
|
||||
const { data, error } = await supabase.rpc('get_account_gl_lines_for_matching', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
p_include_matched: includeMatched,
|
||||
})
|
||||
|
||||
if (error || !data) return []
|
||||
// Paginated + ordered chronologically with the unique line_id tiebreaker,
|
||||
// for the same reasons as fetchUnlinkedGLLines.
|
||||
let data: GLLineForMatching[]
|
||||
try {
|
||||
data = await fetchAllRows<GLLineForMatching>(({ from, to }) =>
|
||||
supabase
|
||||
.rpc('get_account_gl_lines_for_matching', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
p_include_matched: includeMatched,
|
||||
})
|
||||
.order('entry_date')
|
||||
.order('voucher_number')
|
||||
.order('line_id')
|
||||
.range(from, to),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
// count(*) can arrive as a bigint string over the wire — coerce defensively.
|
||||
return (data as GLLineForMatching[]).map((line) => ({
|
||||
return data.map((line) => ({
|
||||
...line,
|
||||
linked_transaction_count: Number(line.linked_transaction_count) || 0,
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user