Files
accounted/tests/pg/get_unlinked_1930_lines.pg.test.ts
T
Mattsson 8a6ce7093e feat: implement skattekonto drift detection and alerting (#525)
* feat: implement skattekonto drift detection and alerting

- Add skattekonto drift computation logic to compare Skatteverket's saldo with GL 1630 sum.
- Implement alerting mechanism for significant drift changes, with throttling to prevent alert spamming.
- Introduce database functions to sum GL 1630 entries and list unbooked skattekonto rows.

feat: create own account transfer detection

- Develop logic to detect transfers between a company's own cash accounts based on counterparty IBAN.
- Implement tests to validate detection logic under various scenarios, including matching and non-matching IBANs.

feat: establish cash accounts as a first-class entity

- Create cash_accounts table to manage routable cash accounts, replacing ad-hoc JSONB structures.
- Implement functions for listing, upserting, and managing cash accounts, including primary account designation.

feat: enhance GL line reconciliation functionality

- Modify get_unlinked_1930_lines RPC to accept any account number for reconciliation, improving flexibility for different currencies.
- Update related functions to ensure compatibility with the new cash_accounts structure.

feat: capture counterparty IBAN in transactions

- Add counterparty_iban column to transactions table to facilitate intra-account transfer detection.
- Create index for efficient lookups based on counterparty IBAN.

* feat: Enhance cash account handling and reconciliation processes

- Updated reconciliation routes to enforce cash account validation for all account numbers, including '1930'.
- Improved error handling for unknown cash accounts in reconciliation status and unmatched entries routes.
- Changed CashAccountSelector to use sessionStorage instead of localStorage for better data privacy.
- Fixed mapping for employer payroll taxes to route to the correct account (2730 instead of 2731).
- Added safety checks for company IDs in the guessCounterAccount function to prevent injection vulnerabilities.
- Introduced atomic RPC for setting primary cash accounts to avoid intermediate states during updates.
- Seeded default cash accounts for new companies to ensure reconciliation routes are accessible from day one.
- Updated email notifications for drift detection to avoid exposing sensitive financial data.
- Enhanced bank reconciliation logic to handle multi-currency transactions correctly.
- Renamed and updated tests to reflect changes in the underlying RPCs and ensure accurate coverage.
- Migrated existing cash account rules to correct mappings in compliance with Swedish accounting standards.
2026-05-19 16:10:18 +02:00

172 lines
6.2 KiB
TypeScript

/**
* pg-real test for get_unlinked_gl_lines.
*
* 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. The RPC was renamed from
* get_unlinked_1930_lines in 20260519120000_get_unlinked_gl_lines.sql; the
* default p_account_number of '1930' keeps these assertions valid.
*/
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_gl_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_gl_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. Use named notation so we don't have
// to repeat the '1930' default just to reach the date params.
const { rows } = await getPool().query(
`SELECT entry_date FROM public.get_unlinked_gl_lines(
p_company_id => $1, p_date_from => $2, p_date_to => $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_gl_lines($1)`,
[companyA],
)
const { rows: rowsB } = await getPool().query(
`SELECT 1 FROM public.get_unlinked_gl_lines($1)`,
[companyB],
)
expect(rowsA).toHaveLength(1)
expect(rowsB).toHaveLength(1)
})
})