Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count The "Gamla transaktioner" widget counted transactions that had been ignored or already marked as is_business=true but not yet booked, so users saw a nag for a row they had already dealt with — and the /transactions inbox correctly hid it. Align the count with the inbox criterion (is_business IS NULL, is_ignored = false) so the widget clears when the row leaves the inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): read entity_type from settings response wrapper The transactions page read entityRes.entity_type directly, but /api/settings returns { data: { entity_type, ... } }. The expression was always undefined, so setEntityType never fired and entityType stayed at its initial 'enskild_firma'. The template picker's entity_type filter then dropped every aktiebolag-tagged user template for AB customers — only entity_type='all' templates made it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stale templates bank sync journal entry from transaction * fixed pr comments * fixed pr comment --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
/**
|
||||
* Chained-correction invariant: a posted entry of source_type='correction'
|
||||
* can itself be reversed and corrected — the chain just grows. The UI used
|
||||
* to block this; the storno-service never did. This test asserts the DB
|
||||
* layer accepts the full two-level chain (CHECK constraint, FK, immutability
|
||||
* trigger), so any future migration that accidentally tightens one of those
|
||||
* will fail loudly here instead of silently breaking BFL 5 kap. 5 § flows.
|
||||
*
|
||||
* The flow this mirrors:
|
||||
* 1. Original posted (manual)
|
||||
* 2. correctEntry → storno-1 + correction-1, original → reversed
|
||||
* 3. correctEntry on correction-1 → storno-2 + correction-2,
|
||||
* correction-1 → reversed
|
||||
*
|
||||
* We drive the SQL directly because correctEntry uses the Supabase JS client
|
||||
* which is out of scope for the pg-real harness (see year-end-invariants.pg
|
||||
* for the same rationale).
|
||||
*/
|
||||
describe('chained correction (pg-real)', () => {
|
||||
it('accepts a correction whose original is itself a correction', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const pool = getPool()
|
||||
|
||||
async function insertDraft(opts: {
|
||||
sourceType: string
|
||||
reversesId?: string | null
|
||||
correctionOfId?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await pool.query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status, reverses_id, correction_of_id)
|
||||
VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-15', $5, $6, 'draft', $7, $8)`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
`Entry ${opts.sourceType}`,
|
||||
opts.sourceType,
|
||||
opts.reversesId ?? null,
|
||||
opts.correctionOfId ?? null,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertLines(entryId: string, debitAcc: string, creditAcc: string, amount: number) {
|
||||
await pool.query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
||||
[entryId, debitAcc, amount, creditAcc],
|
||||
)
|
||||
}
|
||||
|
||||
async function commit(entryId: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ voucher_number: number }>(
|
||||
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
|
||||
[companyId, entryId],
|
||||
)
|
||||
return rows[0]!.voucher_number
|
||||
}
|
||||
|
||||
async function markReversed(entryId: string, reversedById: string) {
|
||||
await pool.query(
|
||||
`UPDATE public.journal_entries
|
||||
SET status = 'reversed', reversed_by_id = $2
|
||||
WHERE id = $1 AND status = 'posted'`,
|
||||
[entryId, reversedById],
|
||||
)
|
||||
}
|
||||
|
||||
// === Step 1: original posted ===
|
||||
const originalId = await insertDraft({ sourceType: 'manual' })
|
||||
await insertLines(originalId, '5410', '1930', 1000)
|
||||
await commit(originalId)
|
||||
|
||||
// === Step 2: first storno + first correction ===
|
||||
const storno1Id = await insertDraft({ sourceType: 'storno', reversesId: originalId })
|
||||
await insertLines(storno1Id, '1930', '5410', 1000) // swapped legs
|
||||
await commit(storno1Id)
|
||||
await markReversed(originalId, storno1Id)
|
||||
|
||||
const correction1Id = await insertDraft({
|
||||
sourceType: 'correction',
|
||||
correctionOfId: originalId,
|
||||
})
|
||||
await insertLines(correction1Id, '5420', '1930', 1200)
|
||||
await commit(correction1Id)
|
||||
|
||||
// Sanity: original is reversed, correction1 is posted.
|
||||
const { rows: midRows } = await pool.query<{ id: string; status: string; source_type: string }>(
|
||||
`SELECT id, status, source_type FROM public.journal_entries
|
||||
WHERE id = ANY($1::uuid[])`,
|
||||
[[originalId, correction1Id]],
|
||||
)
|
||||
const midState = Object.fromEntries(midRows.map((r) => [r.id, r]))
|
||||
expect(midState[originalId]?.status).toBe('reversed')
|
||||
expect(midState[correction1Id]?.status).toBe('posted')
|
||||
expect(midState[correction1Id]?.source_type).toBe('correction')
|
||||
|
||||
// === Step 3: storno + correction OF the first correction ===
|
||||
// This is the new path. The DB must accept reverses_id and correction_of_id
|
||||
// pointing at a source_type='correction' entry, and accept a second
|
||||
// entry of source_type='correction' in the same period.
|
||||
const storno2Id = await insertDraft({ sourceType: 'storno', reversesId: correction1Id })
|
||||
await insertLines(storno2Id, '1930', '5420', 1200)
|
||||
await commit(storno2Id)
|
||||
await markReversed(correction1Id, storno2Id)
|
||||
|
||||
const correction2Id = await insertDraft({
|
||||
sourceType: 'correction',
|
||||
correctionOfId: correction1Id,
|
||||
})
|
||||
await insertLines(correction2Id, '5430', '1930', 1500)
|
||||
const correction2Voucher = await commit(correction2Id)
|
||||
|
||||
expect(correction2Voucher).toBeGreaterThan(0)
|
||||
|
||||
// === Final assertions: full chain is intact ===
|
||||
const { rows: finalRows } = await pool.query<{
|
||||
id: string
|
||||
status: string
|
||||
source_type: string
|
||||
reverses_id: string | null
|
||||
correction_of_id: string | null
|
||||
reversed_by_id: string | null
|
||||
}>(
|
||||
`SELECT id, status, source_type, reverses_id, correction_of_id, reversed_by_id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = $1
|
||||
ORDER BY voucher_number`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(finalRows).toHaveLength(5)
|
||||
|
||||
const state = Object.fromEntries(finalRows.map((r) => [r.id, r]))
|
||||
expect(state[originalId]).toMatchObject({
|
||||
status: 'reversed',
|
||||
source_type: 'manual',
|
||||
reversed_by_id: storno1Id,
|
||||
})
|
||||
expect(state[storno1Id]).toMatchObject({
|
||||
status: 'posted',
|
||||
source_type: 'storno',
|
||||
reverses_id: originalId,
|
||||
})
|
||||
expect(state[correction1Id]).toMatchObject({
|
||||
status: 'reversed',
|
||||
source_type: 'correction',
|
||||
correction_of_id: originalId,
|
||||
reversed_by_id: storno2Id,
|
||||
})
|
||||
expect(state[storno2Id]).toMatchObject({
|
||||
status: 'posted',
|
||||
source_type: 'storno',
|
||||
reverses_id: correction1Id,
|
||||
})
|
||||
expect(state[correction2Id]).toMatchObject({
|
||||
status: 'posted',
|
||||
source_type: 'correction',
|
||||
correction_of_id: correction1Id,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -276,6 +276,55 @@ describe('correctEntry', () => {
|
||||
expect(result.corrected).toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts a source_type=correction entry as the original (chained correction, BFL 5 kap. 5 §)', async () => {
|
||||
// The user just corrected entry A → got correction C. They now want to
|
||||
// correct C. Service must not care about source_type of the original —
|
||||
// status='posted' is the only constraint.
|
||||
const correctionAsOriginal = makeJournalEntry({
|
||||
id: 'correction-1',
|
||||
status: 'posted',
|
||||
source_type: 'correction',
|
||||
correction_of_id: 'orig-A',
|
||||
description: 'Rättelse: Test purchase',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
lines: [
|
||||
makeJournalEntryLine({ account_number: '5420', debit_amount: 1200, credit_amount: 0 }),
|
||||
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1200 }),
|
||||
],
|
||||
})
|
||||
const secondReversal = makeJournalEntry({ id: 'reversal-2', reverses_id: 'correction-1' })
|
||||
const secondCorrection = makeJournalEntry({
|
||||
id: 'correction-2',
|
||||
correction_of_id: 'correction-1',
|
||||
source_type: 'correction',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
|
||||
{ data: secondReversal, error: null }, // 1: insert reversal
|
||||
{ data: null, error: null }, // 2: insert reversal lines
|
||||
{ data: null, error: null }, // 3: post reversal
|
||||
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
|
||||
{ data: secondCorrection, error: null }, // 5: insert corrected
|
||||
{ data: null, error: null }, // 6: insert corrected lines
|
||||
{ data: null, error: null }, // 7: post corrected
|
||||
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
|
||||
{ data: { ...secondReversal, lines: [] }, error: null }, // 9: fetch final reversal
|
||||
{ data: { ...secondCorrection, lines: [] }, error: null }, // 10: fetch final corrected
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'correction-1', [
|
||||
{ account_number: '5430', debit_amount: 1500, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1500 },
|
||||
])
|
||||
|
||||
expect(result.reversal.reverses_id).toBe('correction-1')
|
||||
expect(result.corrected.correction_of_id).toBe('correction-1')
|
||||
expect(result.corrected.source_type).toBe('correction')
|
||||
})
|
||||
|
||||
it('emits journal_entry.corrected event', async () => {
|
||||
setupResults()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user