feat(dimensions): voucher-level retro-tagging workbench — reversal pairs hidden by default (#874)

* feat(dimensions): voucher-level retro-tagging workbench, reversal pairs hidden by default

UX rework of the BulkTagWorkbench (follow-up to #867): the verifikat is now
the unit of work, matching how users think ('that invoice belongs to project
X') and how every Swedish bookkeeping tool presents entries.

- /api/dimensions/tagging/lines returns voucher-grouped results via a
  two-step query: filters select QUALIFYING vouchers (line-level predicates
  become 'voucher has such a line' through the inner join), then the
  complete line set for each — tagging a voucher always covers the whole
  verifikat, never the filtered subset of one
- reversal pairs are EXCLUDED by default: an annulled entry and its storno
  net to zero in every dimension bucket as long as both sides carry the
  same tag, so retro-tagging them is a no-op with an asymmetry foot-gun
  attached; 'Visa annullerade' opts them back in, and the blocking
  motverifikat confirmation survives only in that view (correction rebooks
  remain taggable — only the original+storno pair is hidden)
- voucher rows: label, description, date, line count, distinct tag chips +
  'Delvis taggad' state, single total amount (no debit/credit columns);
  chevron expands to per-line rows (signed amount, per-line checkboxes)
  for the mixed case (a voucher split across projects)
- selection stays line-id based under the hood (the retag RPC is per
  line); shift-click ranges operate on vouchers; apply groups by resulting
  map and now chunks to the apply route's 500-line cap; failed vouchers
  auto-expand with their Swedish RPC errors inline
- 'endast otaggade' now means 'vouchers with at least one untagged line';
  the cap counts vouchers (default 150, max 300)

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

* fix: pull counter-vouchers into the annulled view even outside filters (review)

Without this, a pair leg whose counter fell outside the date range would
show no motverifikat warning at all — one-sided tagging would slip
through silently, exactly the Srf U 14 skew the guard exists to prevent.
Also scope the line fetch explicitly through the parent company filter
(defense in depth).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:11:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 45438d4d64
commit 28827c2613
4 changed files with 706 additions and 308 deletions
@@ -1,9 +1,11 @@
/**
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser).
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser,
* voucher-level rework).
*
* Covers: 401, query validation (400), the happy path (flattened DTO,
* date-sorted, total_capped false), the hard-cap contract (limit+1 fetch →
* total_capped true), and the DB error path.
* Covers: 401, query validation (400), the voucher-grouped happy path (two
* queries: qualifying entries → complete line sets), the hard-cap contract
* (limit+1 entries → total_capped true, second query only for the page), the
* empty result short-circuit (no line query), and the DB error paths.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -33,25 +35,46 @@ const noParams = { params: Promise.resolve({}) }
const request = (searchParams?: Record<string, string>) =>
createMockRequest('/api/dimensions/tagging/lines', { searchParams })
interface FlatLine {
interface VoucherLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
}
interface Voucher {
journal_entry_id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
annulled: boolean
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
lines: VoucherLine[]
}
type LinesBody = { data: { lines: FlatLine[]; total_capped: boolean } }
type VouchersBody = { data: { vouchers: Voucher[]; total_capped: boolean } }
/** Raw row as the Supabase select returns it (nested journal_entries). */
/** Raw entry row as the Supabase step-1 select returns it. */
function makeRawEntry(overrides: Record<string, unknown> = {}) {
return {
id: 'entry-1',
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
reversed_by_id: null,
reverses_id: null,
fiscal_period_id: 'period-1',
journal_entry_lines: [{ id: 'line-1' }],
...overrides,
}
}
/** Raw line row as the Supabase step-2 select returns it. */
function makeRawLine(overrides: Record<string, unknown> = {}) {
return {
id: 'line-1',
@@ -60,15 +83,7 @@ function makeRawLine(overrides: Record<string, unknown> = {}) {
credit_amount: 0,
dimensions: { '1': 'KS01' },
journal_entry_id: 'entry-1',
journal_entries: {
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
reversed_by_id: null,
reverses_id: null,
fiscal_period_id: 'period-1',
},
sort_order: 0,
...overrides,
}
}
@@ -108,89 +123,166 @@ describe('GET /api/dimensions/tagging/lines', () => {
expect(response.status).toBe(400)
})
it('returns flattened lines sorted by entry date, total_capped false', async () => {
it('groups complete line sets under their vouchers', async () => {
// Step 1: qualifying entries (annulled linkage rides along for the pair
// guard in the opt-in view).
enqueue({
data: [
makeRawLine({
id: 'line-2',
journal_entries: {
entry_date: '2026-04-01',
voucher_number: 50,
voucher_series: 'A',
description: 'Senare verifikat',
reversed_by_id: 'entry-9',
reverses_id: null,
fiscal_period_id: 'period-1',
},
makeRawEntry(),
makeRawEntry({
id: 'entry-2',
entry_date: '2026-04-01',
voucher_number: 50,
description: 'Senare verifikat',
reversed_by_id: 'entry-9',
}),
makeRawLine({ id: 'line-1', dimensions: {} }),
],
})
// Step 2: the COMPLETE line sets — entry-1 has a line the account filter
// would not have matched; it must still be present (whole-verifikat
// contract, null dimensions normalized to {}).
enqueue({
data: [
makeRawLine(),
makeRawLine({ id: 'line-2', account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions: null, sort_order: 1 }),
makeRawLine({ id: 'line-3', journal_entry_id: 'entry-2', dimensions: {} }),
],
})
const response = await GET(request(), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.total_capped).toBe(false)
expect(body.data.lines).toHaveLength(2)
// Sorted by entry_date: line-1 (2026-03-10) before line-2 (2026-04-01).
expect(body.data.lines[0]).toMatchObject({
id: 'line-1',
account_number: '4010',
debit_amount: 100,
credit_amount: 0,
dimensions: {},
expect(body.data.vouchers).toHaveLength(2)
const [first, second] = body.data.vouchers
expect(first).toMatchObject({
journal_entry_id: 'entry-1',
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
fiscal_period_id: 'period-1',
annulled: false,
})
// Reversal linkage rides along for the storno-pair warning.
expect(body.data.lines[1].reversed_by_id).toBe('entry-9')
expect(first.lines).toHaveLength(2)
expect(first.lines[0]).toMatchObject({ id: 'line-1', dimensions: { '1': 'KS01' } })
expect(first.lines[1]).toMatchObject({ id: 'line-2', account_number: '1930', dimensions: {} })
expect(second.annulled).toBe(true)
expect(second.reversed_by_id).toBe('entry-9')
expect(second.lines.map((l) => l.id)).toEqual(['line-3'])
})
it('normalizes a null dimensions map to {}', async () => {
enqueue({ data: [makeRawLine({ dimensions: null })] })
const response = await GET(request(), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
expect(status).toBe(200)
expect(body.data.lines[0].dimensions).toEqual({})
})
it('caps the result at limit and reports total_capped', async () => {
// limit=2 → route fetches 3; a third row means "there is more".
it('caps the result at limit vouchers and reports total_capped', async () => {
// limit=2 → route fetches 3 entries; a third means "there is more".
enqueue({
data: [
makeRawLine({ id: 'line-1' }),
makeRawLine({ id: 'line-2' }),
makeRawLine({ id: 'line-3' }),
makeRawEntry({ id: 'entry-1' }),
makeRawEntry({ id: 'entry-2' }),
makeRawEntry({ id: 'entry-3' }),
],
})
enqueue({
data: [
makeRawLine({ id: 'line-1', journal_entry_id: 'entry-1' }),
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
],
})
const response = await GET(request({ limit: '2' }), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.lines).toHaveLength(2)
expect(body.data.vouchers).toHaveLength(2)
expect(body.data.vouchers.map((v) => v.journal_entry_id)).toEqual(['entry-1', 'entry-2'])
expect(body.data.total_capped).toBe(true)
})
it('returns an empty list when nothing matches', async () => {
it('short-circuits an empty entry page without a line query', async () => {
enqueue({ data: [] })
const response = await GET(request({ only_untagged: '1' }), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.lines).toEqual([])
expect(body.data.vouchers).toEqual([])
expect(body.data.total_capped).toBe(false)
})
it('returns 500 when the query fails', async () => {
it('include_annulled=1 pulls in a counter-voucher that fell outside the filters', async () => {
// Step 1 returns only the storno leg (its original, entry-0, is outside
// the date range). The route must fetch entry-0 anyway — otherwise the
// workbench's motverifikat guard cannot see the missing leg and one-sided
// tagging slips through silently.
enqueue({ data: [makeRawEntry({ reverses_id: 'entry-0', entry_date: '2026-05-01' })] })
// Counter-voucher fetch by id.
enqueue({
data: [
makeRawEntry({
id: 'entry-0',
entry_date: '2026-02-01',
voucher_number: 40,
description: 'Original',
reversed_by_id: 'entry-1',
}),
],
})
// Complete line sets for BOTH vouchers.
enqueue({
data: [
makeRawLine({ id: 'line-0', journal_entry_id: 'entry-0' }),
makeRawLine(),
],
})
const response = await GET(request({ include_annulled: '1' }), noParams)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.vouchers).toHaveLength(2)
// Sorted by entry_date: the pulled-in original first.
expect(body.data.vouchers[0]).toMatchObject({
journal_entry_id: 'entry-0',
annulled: true,
lines: [{ id: 'line-0' }],
})
expect(body.data.vouchers[1].annulled).toBe(true)
})
it('include_annulled=1 skips the counter fetch when both legs already qualified', async () => {
enqueue({
data: [
makeRawEntry({ reversed_by_id: 'entry-2' }),
makeRawEntry({ id: 'entry-2', reverses_id: 'entry-1', voucher_number: 43 }),
],
})
// No counter query — next enqueued result is the line fetch.
enqueue({
data: [
makeRawLine(),
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
],
})
const response = await GET(request({ include_annulled: '1' }), noParams)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.vouchers).toHaveLength(2)
expect(body.data.vouchers.every((v) => v.annulled)).toBe(true)
})
it('returns 500 when the entry query fails', async () => {
enqueue({ error: { message: 'relation missing' } })
const response = await GET(request(), noParams)
expect(response.status).toBe(500)
})
it('returns 500 when the line query fails', async () => {
enqueue({ data: [makeRawEntry()] })
enqueue({ error: { message: 'relation missing' } })
const response = await GET(request(), noParams)
+156 -68
View File
@@ -1,18 +1,27 @@
/**
* GET /api/dimensions/tagging/lines — posted journal-entry lines for the bulk
* retro-tagging workbench (dimensions plan PR6 §3).
* GET /api/dimensions/tagging/lines — posted VOUCHERS (with their complete
* line sets) for the bulk retro-tagging workbench (dimensions plan PR6 §3,
* voucher-level rework).
*
* Read-only line browser: filter by period, entry-date range, account range,
* free text (ilike on the entry description) and "only untagged" (empty
* dimensions map). Hard cap instead of pagination for v1 — the route fetches
* limit+1 rows and reports `total_capped: true` so the UI can show a
* "narrow your filter" notice.
* The verifikat is the unit of work: filters select qualifying vouchers
* (period, entry-date range, free text, "has a line in the account range",
* "has an untagged line") and the response carries every line of each
* qualifying voucher so tagging a voucher means tagging a complete verifikat
* — never the filtered subset of one.
*
* Response: 200 { data: { lines: [...], total_capped: boolean } } where each
* line is flattened ({ id, account_number, debit_amount, credit_amount,
* dimensions, journal_entry_id, entry_date, voucher_number, voucher_series,
* description, reversed_by_id, reverses_id, fiscal_period_id }). The reversal
* linkage rides along so the workbench can warn about storno pairs.
* Reversal pairs are EXCLUDED by default: an annulled entry and its storno
* net to zero in every dimension bucket as long as both sides carry the same
* tag, so retro-tagging them is a no-op with an asymmetry foot-gun attached.
* `include_annulled=1` opts them back in (the workbench keeps its blocking
* motverifikat confirmation in that view only).
*
* Hard cap counts VOUCHERS (limit+1 fetch → `total_capped: true`); the UI
* shows a "narrow your filter" notice.
*
* Response: 200 { data: { vouchers: [...], total_capped } } where each
* voucher is { journal_entry_id, entry_date, voucher_number, voucher_series,
* description, annulled, lines: [{ id, account_number, debit_amount,
* credit_amount, dimensions }] }.
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
@@ -24,24 +33,25 @@ import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
ensureInitialized()
interface RawTaggingLine {
interface RawEntry {
id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
}
interface RawLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string> | null
journal_entry_id: string
// Supabase types !inner joins as arrays; for many-to-one (line → entry) it
// returns a single object at runtime (same caveat as lib/reports/general-ledger.ts).
journal_entries: {
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
}
sort_order: number | null
}
export const GET = withRouteContext(
@@ -56,74 +66,152 @@ export const GET = withRouteContext(
if (!validation.success) return validation.response
const q = validation.data
let query = supabase
.from('journal_entry_lines')
// Step 1: qualifying vouchers. Line-level filters (account range, only
// untagged) become "voucher HAS such a line" via the inner join — the
// parent row appears once regardless of how many lines match.
let entryQuery = supabase
.from('journal_entries')
.select(
'id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, company_id, status)',
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, journal_entry_lines!inner(id)',
)
.eq('journal_entries.company_id', companyId)
.eq('company_id', companyId)
// Posted only — drafts are edited directly in the voucher editor and the
// retag RPC rejects them anyway.
.eq('journal_entries.status', 'posted')
.eq('status', 'posted')
if (q.period_id) query = query.eq('journal_entries.fiscal_period_id', q.period_id)
if (q.date_from) query = query.gte('journal_entries.entry_date', q.date_from)
if (q.date_to) query = query.lte('journal_entries.entry_date', q.date_to)
if (q.account_from) query = query.gte('account_number', q.account_from)
if (q.account_to) query = query.lte('account_number', q.account_to)
if (q.include_annulled !== '1') {
// Default view: no reversal pairs. Both sides net to zero in every
// dimension bucket when kept together, so they are pure noise here —
// and hiding them makes tagging one side without the other impossible.
entryQuery = entryQuery.is('reversed_by_id', null).is('reverses_id', null)
}
if (q.period_id) entryQuery = entryQuery.eq('fiscal_period_id', q.period_id)
if (q.date_from) entryQuery = entryQuery.gte('entry_date', q.date_from)
if (q.date_to) entryQuery = entryQuery.lte('entry_date', q.date_to)
if (q.text) {
// Escape LIKE wildcards (\ % _) so they match literally — same posture
// as the journal-entries list route.
query = query.ilike('journal_entries.description', `%${escapeLikePattern(q.text)}%`)
entryQuery = entryQuery.ilike('description', `%${escapeLikePattern(q.text)}%`)
}
if (q.account_from) {
entryQuery = entryQuery.gte('journal_entry_lines.account_number', q.account_from)
}
if (q.account_to) {
entryQuery = entryQuery.lte('journal_entry_lines.account_number', q.account_to)
}
if (q.only_untagged === '1') {
// dimensions is NOT NULL DEFAULT '{}' (substrate migration), so the
// empty-map equality is the complete "untagged" predicate.
query = query.eq('dimensions', '{}')
// empty-map equality is the complete "untagged" predicate. Combined
// with an account range this reads "has an untagged line in the range".
entryQuery = entryQuery.eq('journal_entry_lines.dimensions', '{}')
}
// Deterministic order on the line PK; fetch one row past the cap so the
// response can say "there is more" without a count query.
const { data, error } = await query
const { data: entryData, error: entryError } = await entryQuery
.order('entry_date', { ascending: true })
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.order('id', { ascending: true })
.limit(q.limit + 1)
if (error) {
log.error('tagging line browse failed', error)
return errorResponse(error, log, { requestId })
if (entryError) {
log.error('tagging voucher browse failed', entryError)
return errorResponse(entryError, log, { requestId })
}
const raw = (data ?? []) as unknown as RawTaggingLine[]
const totalCapped = raw.length > q.limit
const page = totalCapped ? raw.slice(0, q.limit) : raw
const rawEntries = (entryData ?? []) as unknown as RawEntry[]
const totalCapped = rawEntries.length > q.limit
const entries = totalCapped ? rawEntries.slice(0, q.limit) : rawEntries
const lines = page
.map((l) => ({
if (entries.length === 0) {
return NextResponse.json({ data: { vouchers: [], total_capped: totalCapped } })
}
// Pair completion (annulled view only): if a pair leg qualified but its
// counter-entry fell outside the filters (e.g. the storno is in a later
// month than the date range), pull the counter in anyway. Without it the
// workbench's motverifikat guard cannot see the missing leg and the user
// could tag one side alone — exactly the P&L skew the guard exists to
// prevent (Srf U 14 gross reporting). Counters ride on top of the cap:
// they are required for correctness, not part of the browsed page.
if (q.include_annulled === '1') {
const present = new Set(entries.map((e) => e.id))
const counterIds = [
...new Set(
entries
.flatMap((e) => [e.reversed_by_id, e.reverses_id])
.filter((id): id is string => id !== null && !present.has(id)),
),
]
if (counterIds.length > 0) {
const { data: counterData, error: counterError } = await supabase
.from('journal_entries')
.select(
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id',
)
.eq('company_id', companyId)
.eq('status', 'posted')
.in('id', counterIds)
if (counterError) {
log.error('tagging counter-voucher fetch failed', counterError)
return errorResponse(counterError, log, { requestId })
}
entries.push(...((counterData ?? []) as unknown as RawEntry[]))
entries.sort(
(a, b) =>
a.entry_date.localeCompare(b.entry_date) ||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
a.id.localeCompare(b.id),
)
}
}
// Step 2: the COMPLETE line set for each qualifying voucher, so voucher-
// level tagging always covers the whole verifikat. The entry IDs already
// come from the company-filtered step-1 query; the explicit parent scope
// here is defense in depth (repo convention).
const { data: lineData, error: lineError } = await supabase
.from('journal_entry_lines')
.select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)')
.eq('journal_entries.company_id', companyId)
.in('journal_entry_id', entries.map((e) => e.id))
.order('journal_entry_id', { ascending: true })
.order('sort_order', { ascending: true })
.order('id', { ascending: true })
if (lineError) {
log.error('tagging voucher line fetch failed', lineError)
return errorResponse(lineError, log, { requestId })
}
const linesByEntry = new Map<string, RawLine[]>()
for (const line of (lineData ?? []) as unknown as RawLine[]) {
const bucket = linesByEntry.get(line.journal_entry_id) ?? []
bucket.push(line)
linesByEntry.set(line.journal_entry_id, bucket)
}
const vouchers = entries.map((e) => ({
journal_entry_id: e.id,
entry_date: e.entry_date,
voucher_number: e.voucher_number,
voucher_series: e.voucher_series,
description: e.description,
annulled: Boolean(e.reversed_by_id || e.reverses_id),
reversed_by_id: e.reversed_by_id,
reverses_id: e.reverses_id,
fiscal_period_id: e.fiscal_period_id,
lines: (linesByEntry.get(e.id) ?? []).map((l) => ({
id: l.id,
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
dimensions: l.dimensions ?? {},
journal_entry_id: l.journal_entry_id,
entry_date: l.journal_entries.entry_date,
voucher_number: l.journal_entries.voucher_number,
voucher_series: l.journal_entries.voucher_series,
description: l.journal_entries.description,
reversed_by_id: l.journal_entries.reversed_by_id,
reverses_id: l.journal_entries.reverses_id,
fiscal_period_id: l.journal_entries.fiscal_period_id,
}))
// Presentation order: date, then voucher, then line id. Sorting happens
// after the cap (the cap follows insertion-ordered PKs) — acceptable for
// the v1 hard-cap contract; the UI shows a narrow-your-filter notice.
.sort(
(a, b) =>
a.entry_date.localeCompare(b.entry_date) ||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
a.id.localeCompare(b.id),
)
})),
}))
return NextResponse.json({ data: { lines, total_capped: totalCapped } })
return NextResponse.json({ data: { vouchers, total_capped: totalCapped } })
},
)