* fix(reconciliation): exclude opening balance from unmatched-1930 set (#443) Reconciliation incorrectly counted IB (source_type='opening_balance') vouchers on 1930 as unmatched bank transactions and included them in the GL period total. Result: an SIE-imported book always showed a phantom unmatched voucher and a difference equal to the IB amount, even when every real bank transaction was matched. This was the root of the "reconciliation says broken but numbers look right" complaint. The fix is two-sided: - get_unlinked_1930_lines RPC now skips source_type='opening_balance' rows. IB has no counterpart in the bank feed by definition. - getReconciliationStatus splits glOpeningBalance out of the period movement and computes difference = bankTotal − glPeriodMovement (was bankTotal − glBalance). gl_1930_balance is preserved unchanged for back-compat; new gl_1930_period_movement and gl_1930_opening_balance fields let the UI show both perspectives. - BankReconciliationView shows period movement vs bank in the diff and folds IB into a small caption ("Ingående balans … räknas inte i avstämningen") only when non-zero. Mirrors Fortnox's "Ingående saldo vid avstämningsstart" pattern — IB is verified separately, never surfaced as unmatched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(reconciliation): use IS DISTINCT FROM for NULL-safe source_type filter `source_type <> 'opening_balance'` evaluates to NULL (not TRUE) when `source_type IS NULL` under SQL three-valued logic. journal_entries.source_type is NOT NULL today so the practical difference is zero, but if that constraint is ever relaxed `<>` would silently drop NULL rows from the unmatched-1930 set, making them invisible to reconciliation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): use valid source_type 'bank_transaction' in pg-real fixture CI pg-real failed because 'bank_import' is not in the journal_entries source_type CHECK constraint. The valid value for bank-imported transactions is 'bank_transaction' (per migration 20260513170001 + earlier). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(reconciliation): deprecate gl_1930_balance on ReconciliationStatus Mark the IB-inclusive balance field as @deprecated on both the server-side ReconciliationStatus interface and the component-side mirror. Downstream consumers that read this field expecting it to drive the diff will be off by the IB amount whenever a SIE-imported opening balance exists on 1930. The new gl_1930_period_movement field is the correct basis for the diff and is what the difference field is computed against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(reconciliation): tighten review feedback on PR #485 Three tweaks from the Swedish-accounting bot review: 1. Drop the gl_1930_period_movement ?? gl_1930_balance fallback in BankReconciliationView. gnubok has no persisted reconciliation snapshots and Vercel deploys atomically, so the old-server/new-client case can't occur — the fallback was defensive code for an impossible state. Per CLAUDE.md: don't add back-compat shims when you can just change the code. gl_1930_period_movement and gl_1930_opening_balance are now required on the type. 2. Clarify in the migration comment that source_type='opening_balance' is reserved for the fiscal-year IB voucher (always at period_start). The bot questioned whether the unconditional filter could hide a mid-year corrective IB entry; document the invariant — mid-year corrections use source_type='correction' or 'manual', never 'opening_balance'. 3. Test fixture 2099 → 2091. 2099 is årets resultat (current year); 2091 is balanserad vinst/förlust, the realistic counterpart for a carried-forward bank IB. RPC filters on 1930 only so this is cosmetic but matches what a real SIE import would produce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,13 @@ const METHOD_LABELS: Record<string, string> = {
|
||||
|
||||
interface ReconciliationStatus {
|
||||
bank_transaction_total: number
|
||||
/**
|
||||
* @deprecated Kept on the server response for back-compat. The UI no longer
|
||||
* reads it — `gl_1930_period_movement` is required.
|
||||
*/
|
||||
gl_1930_balance: number
|
||||
gl_1930_period_movement: number
|
||||
gl_1930_opening_balance: number
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
matched_count: number
|
||||
@@ -295,12 +301,14 @@ export function BankReconciliationView() {
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Banktransaktioner (summa)</span>
|
||||
<span>Banktransaktioner i perioden</span>
|
||||
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_balance)}</span>
|
||||
<span>Bokfört på <AccountNumber number="1930" /> i perioden</span>
|
||||
<span className="font-mono">
|
||||
{formatCurrency(status.gl_1930_period_movement)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
@@ -308,6 +316,13 @@ export function BankReconciliationView() {
|
||||
{formatCurrency(status.difference)}
|
||||
</span>
|
||||
</div>
|
||||
{status.gl_1930_opening_balance !== 0 && (
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
Ingående balans (IB) på <AccountNumber number="1930" />:{' '}
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_opening_balance)}</span>
|
||||
{' '}— räknas inte i avstämningen.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
|
||||
<span>Matchade: {status.matched_count}</span>
|
||||
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runReconciliation,
|
||||
manualLink,
|
||||
unlinkReconciliation,
|
||||
getReconciliationStatus,
|
||||
} from '../bank-reconciliation'
|
||||
import type { UnlinkedGLLine } from '../bank-reconciliation'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
@@ -554,3 +555,134 @@ describe('unlinkReconciliation', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// getReconciliationStatus — IB exclusion (PR 3 of #443)
|
||||
// ============================================================
|
||||
|
||||
describe('getReconciliationStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
const buildChain = (): 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)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
it('reports is_reconciled=true when only the IB voucher is unmatched on 1930', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// 1) transactions: 1000 SEK matched (journal_entry_id set)
|
||||
enqueue({
|
||||
data: [{ amount: 1000, journal_entry_id: 'je-tx', reconciliation_method: 'auto_exact' }],
|
||||
})
|
||||
// 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' } },
|
||||
],
|
||||
})
|
||||
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
|
||||
enqueue({ data: [] })
|
||||
|
||||
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
||||
|
||||
expect(status.gl_1930_balance).toBe(51000) // includes IB
|
||||
expect(status.gl_1930_period_movement).toBe(1000) // excludes IB
|
||||
expect(status.gl_1930_opening_balance).toBe(50000)
|
||||
expect(status.bank_transaction_total).toBe(1000)
|
||||
expect(status.difference).toBe(0)
|
||||
expect(status.is_reconciled).toBe(true)
|
||||
expect(status.unmatched_gl_line_count).toBe(0)
|
||||
})
|
||||
|
||||
it('reports is_reconciled=false and a non-zero difference when a real bank tx is unmatched', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// 1) transactions: 1500 total, only 1000 matched
|
||||
enqueue({
|
||||
data: [
|
||||
{ amount: 1000, journal_entry_id: 'je-1', reconciliation_method: null },
|
||||
{ amount: 500, journal_entry_id: null, reconciliation_method: null },
|
||||
],
|
||||
})
|
||||
// 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' } },
|
||||
],
|
||||
})
|
||||
// 3) RPC: empty
|
||||
enqueue({ data: [] })
|
||||
|
||||
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
||||
|
||||
expect(status.bank_transaction_total).toBe(1500)
|
||||
expect(status.gl_1930_period_movement).toBe(1000)
|
||||
expect(status.gl_1930_opening_balance).toBe(50000)
|
||||
expect(status.difference).toBe(500) // bank > GL period movement
|
||||
expect(status.is_reconciled).toBe(false)
|
||||
expect(status.unmatched_transaction_count).toBe(1)
|
||||
})
|
||||
|
||||
it('handles companies with no IB on 1930 (period_movement === gl_balance)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
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' } }],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
||||
|
||||
expect(status.gl_1930_opening_balance).toBe(0)
|
||||
expect(status.gl_1930_period_movement).toBe(100)
|
||||
expect(status.gl_1930_balance).toBe(100)
|
||||
expect(status.difference).toBe(0)
|
||||
expect(status.is_reconciled).toBe(true)
|
||||
})
|
||||
|
||||
it('handles array-shaped journal_entries embed (Supabase wide typing)', async () => {
|
||||
// Supabase typings sometimes widen embedded relations to arrays. The
|
||||
// implementation handles both shapes — verify here.
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
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' }] },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
||||
|
||||
expect(status.gl_1930_opening_balance).toBe(1000)
|
||||
expect(status.gl_1930_period_movement).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,21 @@ export interface ReconciliationRunResult {
|
||||
|
||||
export interface ReconciliationStatus {
|
||||
bank_transaction_total: number
|
||||
/**
|
||||
* @deprecated Use `gl_1930_period_movement` for the reconciliation diff. This
|
||||
* field is preserved for back-compat with persisted status snapshots produced
|
||||
* before the IB-exclusion change; new consumers reading this to compute the
|
||||
* "real" difference will be off by the IB amount whenever a SIE-imported
|
||||
* opening balance exists on 1930. The `difference` field on this interface
|
||||
* is computed against `gl_1930_period_movement`, not this.
|
||||
*/
|
||||
gl_1930_balance: number
|
||||
/** Ledger movement on 1930 excluding source_type='opening_balance' lines. */
|
||||
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
|
||||
/** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
matched_count: number
|
||||
@@ -232,10 +246,13 @@ export async function getReconciliationStatus(
|
||||
|
||||
const { data: transactions } = await txQuery
|
||||
|
||||
// Get GL bank account lines (all, not just unlinked)
|
||||
// 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.
|
||||
let glQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
|
||||
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status, source_type)')
|
||||
.eq('account_number', bankAccount)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
@@ -245,16 +262,38 @@ export async function getReconciliationStatus(
|
||||
|
||||
const { data: glLines } = await glQuery
|
||||
|
||||
type GlLineRow = {
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
journal_entries: { source_type?: string | null } | { source_type?: string | null }[] | null
|
||||
}
|
||||
function isOpeningBalance(line: GlLineRow): boolean {
|
||||
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'
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const bankTotal = (transactions || []).reduce(
|
||||
(sum, tx) => sum + (Number(tx.amount) || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const glBalance = (glLines || []).reduce(
|
||||
const allLines = (glLines || []) as GlLineRow[]
|
||||
const glBalance = allLines.reduce(
|
||||
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
|
||||
0
|
||||
)
|
||||
const glOpeningBalance = allLines
|
||||
.filter(isOpeningBalance)
|
||||
.reduce(
|
||||
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
|
||||
0
|
||||
)
|
||||
const glPeriodMovement = glBalance - glOpeningBalance
|
||||
|
||||
const matchedCount = (transactions || []).filter(
|
||||
(tx) => tx.journal_entry_id !== null
|
||||
@@ -264,14 +303,17 @@ export async function getReconciliationStatus(
|
||||
(tx) => tx.journal_entry_id === null
|
||||
).length
|
||||
|
||||
// Unlinked GL lines count
|
||||
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
|
||||
// 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||
|
||||
const difference = Math.round((bankTotal - glBalance) * 100) / 100
|
||||
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
|
||||
|
||||
return {
|
||||
bank_transaction_total: Math.round(bankTotal * 100) / 100,
|
||||
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,
|
||||
difference,
|
||||
is_reconciled: Math.abs(difference) < 0.01,
|
||||
matched_count: matchedCount,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Exclude opening_balance vouchers from the unmatched-1930 set.
|
||||
--
|
||||
-- IB lines (source_type = 'opening_balance', typically created by SIE import
|
||||
-- or year-end carry-over) post to 1930 on period_start. They have no
|
||||
-- counterpart in the bank feed by definition — the bank statement starts at
|
||||
-- IB and accumulates from there. Counting them as "unmatched" produces a
|
||||
-- phantom voucher in the reconciliation UI and a difference equal to the IB
|
||||
-- amount, even when every real bank transaction is matched.
|
||||
--
|
||||
-- Supersedes the prior definitions in:
|
||||
-- - supabase/migrations/20240101000030_bank_reconciliation.sql
|
||||
-- - supabase/migrations/20260401100000_fix_unlinked_1930_lines_company_id.sql
|
||||
-- - supabase/migrations/20260415000000_schema_sync.sql
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_unlinked_1930_lines(uuid, date, date);
|
||||
|
||||
CREATE FUNCTION public.get_unlinked_1930_lines(
|
||||
p_company_id UUID,
|
||||
p_date_from DATE DEFAULT NULL,
|
||||
p_date_to DATE DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
line_id UUID,
|
||||
journal_entry_id UUID,
|
||||
debit_amount NUMERIC,
|
||||
credit_amount NUMERIC,
|
||||
line_description TEXT,
|
||||
entry_date DATE,
|
||||
voucher_number INT,
|
||||
voucher_series TEXT,
|
||||
entry_description TEXT,
|
||||
source_type TEXT
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT
|
||||
jel.id AS line_id,
|
||||
je.id AS journal_entry_id,
|
||||
jel.debit_amount,
|
||||
jel.credit_amount,
|
||||
jel.line_description,
|
||||
je.entry_date,
|
||||
je.voucher_number,
|
||||
je.voucher_series,
|
||||
je.description AS entry_description,
|
||||
je.source_type
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE jel.account_number = '1930'
|
||||
AND je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
-- Unconditional exclusion. By gnubok's data model 'opening_balance' is
|
||||
-- reserved for the fiscal-year IB voucher (always posts on period_start);
|
||||
-- mid-year corrective entries use 'correction' or 'manual'. So this filter
|
||||
-- can't accidentally hide a legitimate mid-period unmatched entry — there
|
||||
-- is no such thing as a mid-period opening_balance.
|
||||
--
|
||||
-- IS DISTINCT FROM is NULL-safe. Today journal_entries.source_type is
|
||||
-- NOT NULL, so the only behavioural difference vs `<>` is defensive: if the
|
||||
-- NOT NULL constraint is ever relaxed, `<>` would silently drop NULL rows
|
||||
-- (NULL <> 'x' evaluates to NULL, not TRUE), making them invisible to
|
||||
-- reconciliation. IS DISTINCT FROM treats NULL as a distinct value.
|
||||
AND je.source_type IS DISTINCT FROM 'opening_balance'
|
||||
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
||||
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.transactions t
|
||||
WHERE t.journal_entry_id = je.id
|
||||
AND t.company_id = p_company_id
|
||||
)
|
||||
ORDER BY je.entry_date, je.voucher_number;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* pg-real test for get_unlinked_1930_lines (PR 3 of erp-mafia/gnubok#443).
|
||||
*
|
||||
* Verifies the RPC excludes opening_balance vouchers from the unmatched-1930
|
||||
* set, while preserving the existing behavior for posted bank-import vouchers,
|
||||
* date-range filtering, and company scoping.
|
||||
*
|
||||
* Migration: 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures'
|
||||
|
||||
async function insertPostedJournalEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
entryDate: string
|
||||
sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import'
|
||||
voucherNumber: number
|
||||
amount?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const amount = params.amount ?? 1000
|
||||
// Insert as posted directly. This bypasses commit_journal_entry's voucher
|
||||
// sequencing; that's fine for testing the read-side RPC, which only cares
|
||||
// about (account_number, status, source_type, date_range, link presence).
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted')`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.fiscalPeriodId,
|
||||
params.voucherNumber,
|
||||
params.entryDate,
|
||||
`Test ${params.sourceType}`,
|
||||
params.sourceType,
|
||||
],
|
||||
)
|
||||
// Balanced pair on 1930 + 2091 (balanserad vinst/förlust — the realistic
|
||||
// carried-forward counterpart for an IB on a bank account; harmless for the
|
||||
// other source_types where the test only cares about the 1930 side).
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', $2, 0),
|
||||
($1, '2091', 0, $2)`,
|
||||
[id, amount],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => {
|
||||
it('excludes opening_balance vouchers from the unmatched-1930 set', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
})
|
||||
|
||||
// Three vouchers on 1930, all posted, none linked to a transaction:
|
||||
// IB voucher (source_type='opening_balance') — should be EXCLUDED
|
||||
// Bank import voucher — should be RETURNED
|
||||
// Manual voucher — should be RETURNED
|
||||
await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
voucherNumber: 1,
|
||||
amount: 50000,
|
||||
})
|
||||
const bankEntryId = await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-03-15',
|
||||
sourceType: 'bank_transaction',
|
||||
voucherNumber: 2,
|
||||
amount: 1500,
|
||||
})
|
||||
const manualEntryId = await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-04-20',
|
||||
sourceType: 'manual',
|
||||
voucherNumber: 3,
|
||||
amount: 200,
|
||||
})
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT journal_entry_id, source_type FROM public.get_unlinked_1930_lines($1)`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
const returnedIds = new Set(rows.map((r) => r.journal_entry_id))
|
||||
expect(returnedIds.has(bankEntryId)).toBe(true)
|
||||
expect(returnedIds.has(manualEntryId)).toBe(true)
|
||||
// IB voucher should NOT be returned regardless of company/date scope.
|
||||
expect(rows.find((r) => r.source_type === 'opening_balance')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still applies date_from / date_to filtering', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
})
|
||||
|
||||
await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-02-01',
|
||||
sourceType: 'bank_transaction',
|
||||
voucherNumber: 10,
|
||||
})
|
||||
await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-08-01',
|
||||
sourceType: 'bank_transaction',
|
||||
voucherNumber: 11,
|
||||
})
|
||||
|
||||
// Window covers only the second voucher.
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT entry_date FROM public.get_unlinked_1930_lines($1, $2, $3) ORDER BY entry_date`,
|
||||
[companyId, '2026-07-01', '2026-12-31'],
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].entry_date.toISOString().slice(0, 10)).toBe('2026-08-01')
|
||||
})
|
||||
|
||||
it('scopes to the requested company only', async () => {
|
||||
const userA = await insertAuthUser()
|
||||
const userB = await insertAuthUser()
|
||||
const companyA = await insertCompany({ createdBy: userA, name: 'A' })
|
||||
const companyB = await insertCompany({ createdBy: userB, name: 'B' })
|
||||
const fpA = await insertFiscalPeriod({ userId: userA, companyId: companyA })
|
||||
const fpB = await insertFiscalPeriod({ userId: userB, companyId: companyB })
|
||||
|
||||
await insertPostedJournalEntry({
|
||||
userId: userA, companyId: companyA, fiscalPeriodId: fpA,
|
||||
entryDate: '2026-03-01', sourceType: 'bank_transaction', voucherNumber: 1,
|
||||
})
|
||||
await insertPostedJournalEntry({
|
||||
userId: userB, companyId: companyB, fiscalPeriodId: fpB,
|
||||
entryDate: '2026-03-01', sourceType: 'bank_transaction', voucherNumber: 1,
|
||||
})
|
||||
|
||||
const { rows: rowsA } = await getPool().query(
|
||||
`SELECT 1 FROM public.get_unlinked_1930_lines($1)`,
|
||||
[companyA],
|
||||
)
|
||||
const { rows: rowsB } = await getPool().query(
|
||||
`SELECT 1 FROM public.get_unlinked_1930_lines($1)`,
|
||||
[companyB],
|
||||
)
|
||||
expect(rowsA).toHaveLength(1)
|
||||
expect(rowsB).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user