feat(bookkeeping): make inline rattelse discoverable on the verifikat page (#1554) (#2011)

* feat(bookkeeping): make inline rättelse discoverable on the verifikat page (#1554)

A user who wanted Fortnox-style "stryk rader" went looking on the
verifikat page and concluded the feature did not exist: since #1739 every
correction action sits behind an icon-only ⋯ menu, nothing says which
correction track applies when, and the struck-line marker showed only a
date.

- Promote "Stryk rader i verifikatet" to a visible outline button for a
  posted, non-structural entry whose period the period-status endpoint
  reports as open; the ⋯ item stays so the menu remains the complete list.
- Add the convention-7 "?" after the H1 with the two-sentence track rule:
  inline rättelse while the period is open and unlocked, storno once it is
  locked, closed or declared.
- The rattelse-log route now returns an additive actor_label resolved
  from profiles via the service client (same precedent as
  behandlingshistorik); struck rows read "Struken {date} av {actor}" and
  the Rättelsehistorik rows carry the actor beside the date.

No change to the RPCs, the log table, or which corrections are legal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(bookkeeping): address review findings on inline rättelse discoverability (#1554)

- Tie the un-awaited period-status fetch to the fetchData run that issued
  it (monotonic request ref), so an earlier response resolving last can no
  longer set periodStatus='open' for an entry in a locked period and promote
  the "Stryk rader" button the RPC would refuse.
- Align the "?" help copy with what the system enforces: storno is the only
  path once the period is locked or closed; a VAT-declared month is stated
  as a caveat (same wording as the StrikeLinesDialog explainer), not as a
  gate the product does not apply. Both sv and en.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-28 17:15:25 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ca93ef3fb6
commit 22f0647d6c
6 changed files with 191 additions and 28 deletions
@@ -12,6 +12,8 @@ import {
} from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
// Service-role client for the profiles lookup (profiles RLS is self-only).
const service = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -25,17 +27,44 @@ vi.mock('@/lib/company/context', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const createServiceClientMock = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: (...args: unknown[]) => createServiceClientMock(...args),
}))
import { GET } from '../route'
const params = () => createMockRouteParams({ id: 'entry-1' })
const makeGet = () =>
createMockRequest('/api/bookkeeping/journal-entries/entry-1/rattelse-log', { method: 'GET' })
const linesRow = (id: string, actor: string | null, created_at: string) => ({
id,
rattelse_type: 'lines',
old_description: null,
new_description: null,
old_entry_date: null,
new_entry_date: null,
struck_lines: [
{ id: `${id}-struck`, account_number: '5410', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 1 },
],
added_lines: [
{ id: `${id}-added`, account_number: '5420', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 3 },
],
actor,
created_at,
})
type LogRow = { id: string; rattelse_type: string; actor: string | null; actor_label: string | null }
describe('GET /api/bookkeeping/journal-entries/[id]/rattelse-log', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
service.reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
createServiceClientMock.mockReturnValue(service.supabase)
})
it('returns 401 when not authenticated', async () => {
@@ -54,28 +83,14 @@ describe('GET /api/bookkeeping/journal-entries/[id]/rattelse-log', () => {
expect(response.status).toBe(404)
expect(body.error).toContain('hittades inte')
expect(createServiceClientMock).not.toHaveBeenCalled()
})
it('returns the rättelse rows newest first', async () => {
it('returns the rättelse rows newest first with the actor resolved from profiles', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({
data: [
{
id: 'log-2',
rattelse_type: 'lines',
old_description: null,
new_description: null,
old_entry_date: null,
new_entry_date: null,
struck_lines: [
{ id: 'line-1', account_number: '5410', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 1 },
],
added_lines: [
{ id: 'line-9', account_number: '5420', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 3 },
],
actor: 'user-1',
created_at: '2026-07-23T12:00:00Z',
},
linesRow('log-2', 'user-1', '2026-07-23T12:00:00Z'),
{
id: 'log-1',
rattelse_type: 'metadata',
@@ -91,14 +106,65 @@ describe('GET /api/bookkeeping/journal-entries/[id]/rattelse-log', () => {
],
error: null,
})
service.enqueue({
data: [{ id: 'user-1', email: 'anna@example.se', full_name: null }],
error: null,
})
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ data: { id: string; rattelse_type: string }[] }>(response)
const { body } = await parseJsonResponse<{ data: LogRow[] }>(response)
expect(response.status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].id).toBe('log-2')
expect(body.data[0].rattelse_type).toBe('lines')
// Raw actor uuid is kept; the label is additive.
expect(body.data[0].actor).toBe('user-1')
expect(body.data[0].actor_label).toBe('anna@example.se')
expect(body.data[1].actor_label).toBe('anna@example.se')
// One lookup, scoped to exactly the distinct actor ids in the log rows.
expect(service.findCalls('profiles', 'in')).toEqual([['id', ['user-1']]])
})
it('leaves actor_label null and skips the profiles lookup when no row has an actor', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({ data: [linesRow('log-3', null, '2026-07-23T12:00:00Z')], error: null })
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ data: LogRow[] }>(response)
expect(response.status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.data[0].actor_label).toBeNull()
expect(createServiceClientMock).not.toHaveBeenCalled()
expect(service.findCalls('profiles', 'in')).toEqual([])
})
it('still returns 200 with actor_label null when the profiles lookup fails', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({ data: [linesRow('log-4', 'user-2', '2026-07-23T12:00:00Z')], error: null })
service.enqueue({ data: null, error: { message: 'permission denied' } })
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ data: LogRow[] }>(response)
expect(response.status).toBe(200)
expect(body.data[0].actor).toBe('user-2')
expect(body.data[0].actor_label).toBeNull()
})
it('still returns 200 with actor_label null when the service client cannot be created', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({ data: [linesRow('log-5', 'user-2', '2026-07-23T12:00:00Z')], error: null })
createServiceClientMock.mockImplementation(() => {
throw new Error('SUPABASE_SERVICE_ROLE_KEY missing')
})
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ data: LogRow[] }>(response)
expect(response.status).toBe(200)
expect(body.data[0].actor_label).toBeNull()
})
it('returns 500 with a Swedish message when the query fails', async () => {
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createServiceClient } from '@/lib/supabase/server'
import { resolveUserLabelsFromProfiles } from '@/lib/reports/behandlingshistorik'
/**
* GET /api/bookkeeping/journal-entries/[id]/rattelse-log
@@ -7,7 +9,9 @@ import { withRouteContext } from '@/lib/api/with-route-context'
* The entry's inline rättelse history (BFL 5 kap 5 § / 9 §): the immutable
* who/when trail behind every metadata edit and line strike, newest first.
* Struck lines render with strikethrough in the verifikat detail view from
* the struck_lines snapshots here.
* the struck_lines snapshots here. Each row also carries `actor_label`, the
* actor's profile label, so the page can say who struck a line without the
* reader opening a log panel; the raw `actor` uuid is kept unchanged.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.rattelse_log',
@@ -41,6 +45,27 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Kunde inte hämta rättelsehistorik' }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
const rows = (data ?? []) as ({ actor: string | null } & Record<string, unknown>)[]
// Who: `profiles` RLS is self-only, so the label lookup goes through the
// service client, scoped to exactly the actor ids that already appear in
// this company's own log rows (same precedent as behandlingshistorik).
// Best-effort: a failed lookup leaves the label null, never the response.
const actorIds = Array.from(new Set(rows.map((r) => r.actor).filter((a): a is string => !!a)))
let labels = new Map<string, string>()
if (actorIds.length > 0) {
try {
labels = await resolveUserLabelsFromProfiles(createServiceClient(), actorIds)
} catch {
labels = new Map()
}
}
return NextResponse.json({
data: rows.map((row) => ({
...row,
actor_label: row.actor ? (labels.get(row.actor) ?? null) : null,
})),
})
},
)