Files
accounted/tests/pg/mark-entry-as-opening-balance.pg.test.ts
T
MattssonandClaude Fable 5 288915c152 Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:55 +02:00

367 lines
15 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
insertTransaction,
} from '@/tests/pg/fixtures'
import { getClient, getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260613120000_mark_entry_as_opening_balance:
* - the enforce_journal_entry_immutability carve-out (GUC-gated, source_type
* manual/import -> opening_balance only), and
* - the mark_entry_as_opening_balance() SECURITY DEFINER RPC and its guards.
*
* Why this matters: bank reconciliation excludes an opening balance from the
* period movement only when source_type='opening_balance'. A migrated IB booked
* as an ordinary voucher (manual/import) is otherwise immutable, so this is the
* only sanctioned path to re-tag it. The carve-out must NOT open any other edit.
*/
// Posted entry with explicit source_type and lines, created via draft so the
// balance + line-immutability triggers are satisfied. Default lines are a
// realistic bank-account IB (1930 debit / 2099 equity credit).
async function insertPostedEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
sourceType?: string
lines?: Array<{ account: string; debit: number; credit: number }>
}): Promise<string> {
const id = randomUUID()
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', '2026-01-01', 'Ingående balanser 2026', $6, 'draft')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber, params.sourceType ?? 'manual'],
)
const lines = params.lines ?? [
{ account: '1930', debit: 5000, credit: 0 },
{ account: '2099', debit: 0, credit: 5000 },
]
for (const l of lines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, $4)`,
[id, l.account, l.debit, l.credit],
)
}
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [id])
return id
}
describe('mark_entry_as_opening_balance RPC', () => {
it('re-tags a manual bank-account IB to opening_balance and writes an audit row', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
// withUserContext rolls back, so observe the RPC's effects inside the tx.
await withUserContext(userId, async (client) => {
const r = await client.query<{ mark_entry_as_opening_balance: { retagged: boolean; previous_source_type: string } }>(
`SELECT mark_entry_as_opening_balance($1, $2)`,
[companyId, entryId],
)
const result = r.rows[0]!.mark_entry_as_opening_balance
expect(result.retagged).toBe(true)
expect(result.previous_source_type).toBe('manual')
const after = await client.query<{ source_type: string; status: string }>(
`SELECT source_type, status FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(after.rows[0]!.source_type).toBe('opening_balance')
expect(after.rows[0]!.status).toBe('posted')
const audit = await client.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM public.audit_log
WHERE table_name = 'journal_entries' AND record_id = $1
AND description LIKE '%-> opening_balance%'`,
[entryId],
)
expect(Number(audit.rows[0]!.count)).toBeGreaterThanOrEqual(1)
})
})
it('re-tags an import-sourced IB as well', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'admin' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'import',
})
await withUserContext(userId, async (client) => {
await client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId])
const after = await client.query<{ source_type: string }>(
`SELECT source_type FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(after.rows[0]!.source_type).toBe('opening_balance')
})
})
it('refuses callers who are not owner/admin', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'viewer' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/owners and admins/i)
})
})
it('refuses an entry that does not touch a bank/cash account', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
// 1510 receivable / 2440 payable: balance-sheet only, no 19xx bank line.
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
lines: [
{ account: '1510', debit: 5000, credit: 0 },
{ account: '2440', debit: 0, credit: 5000 },
],
})
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/bank\/cash account/i)
})
})
it('refuses an entry whose source_type is not manual/import', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'bank_transaction',
})
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/manual\/import/i)
})
})
it('refuses an entry with a linked bank transaction (20260723160000 guard)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
// A half-settled own-account transfer: since the account-scoped matching
// semantics (same migration), this voucher surfaces in the reconciliation
// view's unmatched table on the OTHER account, making "Märk som IB"
// reachable. Re-tagging it would strand the linked transaction against an
// excluded entry, so the RPC must refuse.
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
lines: [
{ account: '1930', debit: 2500, credit: 0 },
{ account: '1940', debit: 0, credit: 2500 },
],
})
await insertTransaction({
companyId, userId, amount: -2500, journalEntryId: entryId,
})
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/linked bank transactions/i)
})
})
it('refuses re-tagging in a locked fiscal period', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [fiscalPeriodId])
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/locked fiscal period/i)
})
})
})
describe('enforce_journal_entry_immutability: source_type retag carve-out', () => {
it('blocks a bare source_type UPDATE when the bypass flag is NOT set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await expect(
getPool().query(
`UPDATE public.journal_entries SET source_type = 'opening_balance' WHERE id = $1`,
[entryId],
),
).rejects.toThrow(/Committed entries are immutable/i)
})
it('allows a source_type-only retag when the bypass flag IS set (in one tx)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_source_type_retag', 'true', true)`)
await client.query(
`UPDATE public.journal_entries SET source_type = 'opening_balance' WHERE id = $1`,
[entryId],
)
const after = await client.query<{ source_type: string }>(
`SELECT source_type FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(after.rows[0]!.source_type).toBe('opening_balance')
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('blocks even with the flag when another field also changes', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_source_type_retag', 'true', true)`)
await expect(
client.query(
`UPDATE public.journal_entries
SET source_type = 'opening_balance', description = 'tampered'
WHERE id = $1`,
[entryId],
),
).rejects.toThrow(/Committed entries are immutable/i)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('blocks even with the flag when the target source_type is not opening_balance', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_source_type_retag', 'true', true)`)
await expect(
client.query(
`UPDATE public.journal_entries SET source_type = 'system' WHERE id = $1`,
[entryId],
),
).rejects.toThrow(/Committed entries are immutable/i)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
describe('check_transaction_link_not_opening_balance trigger (20260723190000)', () => {
it('refuses INSERTing a transaction linked to an opening_balance entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
})
await expect(
insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId }),
).rejects.toThrow(/opening balance entry/i)
})
it('refuses UPDATEing journal_entry_id to point at an opening_balance entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const obEntryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
})
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
await expect(
getPool().query(
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
[obEntryId, txId],
),
).rejects.toThrow(/opening balance entry/i)
})
it('allows linking to an ordinary entry and unlinking back to NULL', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
[entryId, txId],
)
const linked = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(linked.rows[0]!.journal_entry_id).toBe(entryId)
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = NULL WHERE id = $1`,
[txId],
)
const unlinked = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(unlinked.rows[0]!.journal_entry_id).toBeNull()
})
it('leaves updates that do not change journal_entry_id alone', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const txId = await insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId })
// Same-value SET (e.g. a generic column-list UPDATE) must not raise even
// though the trigger's UPDATE OF column list matches.
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = journal_entry_id, description = 'touched' WHERE id = $1`,
[txId],
)
const after = await getPool().query<{ description: string }>(
`SELECT description FROM public.transactions WHERE id = $1`,
[txId],
)
expect(after.rows[0]!.description).toBe('touched')
})
})
// Local owner seed (company + owner membership + open period).
async function seedOwner(): Promise<{ userId: string; companyId: string; fiscalPeriodId: string }> {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
return { userId, companyId, fiscalPeriodId }
}