ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
7.8 KiB
TypeScript
210 lines
7.8 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' | 'storno' | 'correction'
|
|
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('excludes storno and correction 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',
|
|
})
|
|
|
|
// A storno and a correction voucher on 1930 (the products of the correctEntry
|
|
// flow), plus a normal bank voucher. Stornos/corrections are book-only
|
|
// reversals with no bank-feed counterpart: they must be EXCLUDED so a
|
|
// reconciled period doesn't show them as omatchade verifikationer.
|
|
await insertPostedJournalEntry({
|
|
userId, companyId, fiscalPeriodId,
|
|
entryDate: '2026-05-02', sourceType: 'storno', voucherNumber: 20, amount: 25000,
|
|
})
|
|
await insertPostedJournalEntry({
|
|
userId, companyId, fiscalPeriodId,
|
|
entryDate: '2026-05-02', sourceType: 'correction', voucherNumber: 21, amount: 25000,
|
|
})
|
|
const bankEntryId = await insertPostedJournalEntry({
|
|
userId, companyId, fiscalPeriodId,
|
|
entryDate: '2026-05-03', sourceType: 'bank_transaction', voucherNumber: 22, amount: 1500,
|
|
})
|
|
|
|
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(rows.find((r) => r.source_type === 'storno')).toBeUndefined()
|
|
expect(rows.find((r) => r.source_type === 'correction')).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)
|
|
})
|
|
})
|