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
parent ca93ef3fb6
commit 22f0647d6c
6 changed files with 191 additions and 28 deletions
+1
View File
@@ -1323,6 +1323,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212.
[2026-08-28] Same-bank warning limited to observed one-session banks (SEB only): prod shows Handelsbanken tolerates 4 concurrent sessions, and the generic warning made a user abandon a legitimate renewal. Planned sync-death visibility work was dropped: already shipped via #1271 (health probe), #1727 (stale state), #1969 (cron unstarve).
[2026-08-28] Same-bank warning revised to three tiers after skeptic refutation: hard warn SEB, silent/calm only for verified multi-session banks (Handelsbanken, 4 distinct session_ids observed), legacy hedged warning for unknown banks (fail closed), shared-session siblings exempt (fan-out carries them).
[2026-08-28] Verifikat page promotes "Stryk rader i verifikatet" to a visible outline button only when the period-status endpoint answers open (anything else or unknown keeps it in the ⋯ menu only), and the ⋯ item stays: inline rättelse is the normal path in an open period (#1554), the promoted button must never invite an action the RPC will refuse, and the menu remains the complete action list per the 2026-08-20 detail-page grammar. Struck-line actor labels are resolved server-side via resolveUserLabelsFromProfiles (profiles RLS is self-only) rather than a new column on the log.
[2026-08-28] Employee-save failure reported inline (role=alert in the dialog footer, carrying the requestId) in addition to the single destructive toast, and a missing PERSONNUMMER_ENCRYPTION_KEY typed as 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED rather than INTERNAL_ERROR (#1996): the Radix modal aria-hides the root-layout Toaster while the dialog is open, so the toast is invisible to assistive tech and E2E drivers; TOAST_LIMIT is 1, so a second toast is not an option; and the missing key is a permanent configuration gap where "try again later" is wrong and "contact support" is right (same reasoning as CUSTOMER_PERSONAL_NUMBER_UNREADABLE and INVOICE_SEND_EMAIL_NOT_CONFIGURED). The shared postAction helper was not extended (it takes no body and exposes no requestId): keeping the change local to the dialog avoids widening a helper other panels rely on.
[2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000).
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
+73 -8
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, use } from 'react'
import { useState, useEffect, useCallback, useRef, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
@@ -82,9 +82,14 @@ type RattelseLogRow = {
struck_lines: StruckLineSnapshot[] | null
added_lines: StruckLineSnapshot[] | null
actor: string | null
// Resolved server-side from profiles (rattelse-log route); null when the
// actor is unknown or the lookup failed.
actor_label: string | null
created_at: string
}
type PeriodStatus = 'open' | 'locked' | 'closed'
/**
* Human "who committed this" line from the committed_actor_* snapshot
* (WHO relayed the commit; commit_method records HOW). The claude.ai MCP
@@ -137,6 +142,16 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [showStrikeLines, setShowStrikeLines] = useState(false)
const [showCorrectMetadata, setShowCorrectMetadata] = useState(false)
const [rattelseLog, setRattelseLog] = useState<RattelseLogRow[]>([])
// Lock state of the period covering entry_date, from the period-status
// preview endpoint. Gates the visible "Stryk rader" button: inline rättelse
// only applies while the period is open, so the promoted button hides
// (and the ⋯ item stays) whenever the status is anything else or unknown.
const [periodStatus, setPeriodStatus] = useState<PeriodStatus | null>(null)
// Monotonic id of the latest fetchData run. The period-status fetch is not
// awaited, and fetchData re-runs on every id change and after every dialog
// success, so an earlier response can resolve last; only the response that
// belongs to the most recent run may set periodStatus.
const periodStatusRequestRef = useRef(0)
const [showEdit, setShowEdit] = useState(false)
const [showRecordate, setShowRecordate] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -201,6 +216,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
setEntry(data.entry)
setChain(data.chain)
setIsLastInSeries(data.is_last_in_series ?? false)
// Period lock state for the entry's date, best-effort and not awaited
// (the page paints without it): the endpoint fails closed (a lookup
// failure reads as 'locked'), and a missing answer just keeps the
// strike button in the ⋯ menu only.
setPeriodStatus(null)
const periodRequest = ++periodStatusRequestRef.current
void fetch(
`/api/bookkeeping/fiscal-periods/period-status?date=${encodeURIComponent(data.entry.entry_date)}`,
)
.then(async (periodRes) => {
if (!periodRes.ok) return
const { data: period } = await periodRes.json()
// A newer fetchData has run since; its own response owns the state.
if (periodRequest !== periodStatusRequestRef.current) return
const status = period?.status
setPeriodStatus(status === 'open' || status === 'locked' || status === 'closed' ? status : null)
})
.catch(() => {
if (periodRequest === periodStatusRequestRef.current) setPeriodStatus(null)
})
// Underlag references (linked invoices), best-effort; the verifikat still
// renders if this fails, it just falls back to documents-only.
if (refsRes.ok) {
@@ -397,13 +432,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const struckDisplayLines = rattelseLog
.filter((r) => r.rattelse_type === 'lines')
.flatMap((r) =>
(r.struck_lines ?? []).map((s) => ({ ...s, struck_at: r.created_at }))
(r.struck_lines ?? []).map((s) => ({ ...s, struck_at: r.created_at, struck_by: r.actor_label }))
)
// The struck marker beside a struck row: who and when at a glance, the
// date alone when the actor could not be resolved.
const struckMarker = (s: { struck_at: string; struck_by: string | null }) =>
s.struck_by
? t('struck_marker_by', { date: formatDate(s.struck_at), actor: s.struck_by })
: t('struck_marker', { date: formatDate(s.struck_at) })
// Live and struck lines interleaved by original position.
const displayRows: Array<
| { kind: 'live'; line: JournalEntryLine }
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string } }
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string; struck_by: string | null } }
> = [
...lines.map((l) => ({ kind: 'live' as const, line: l })),
...struckDisplayLines.map((s) => ({ kind: 'struck' as const, line: s })),
@@ -490,6 +532,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const showActions = entry.status === 'posted' || entry.status === 'draft'
const showDelete = entry.status === 'draft' || isLastInSeries
const showRattelseGroup = canCorrect && !isOpeningBalance
// Inline rättelse is the normal correction path while the period is open,
// so "Stryk rader" is promoted to a visible secondary button there (#1554:
// a user who never opens the ⋯ menu concluded the feature did not exist).
// The ⋯ item stays, so the menu remains the complete action list.
const showStrikeButton = showRattelseGroup && canInlineRattelse && periodStatus === 'open'
const underlagAside = (() => {
if (attachmentCount === 0 && references.length === 0) {
@@ -531,6 +578,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<div className="flex flex-wrap items-center gap-3">
{/* data-ph-mask: the title carries the voucher number */}
<h1 data-ph-mask="" className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
{/* Convention 7: which correction track applies when, behind the "?" */}
<HelpPopover>
<p>{t('help_rattelse_tracks_open')}</p>
<p className="mt-2">{t('help_rattelse_tracks_locked')}</p>
</HelpPopover>
<JournalEntryStatusBadge entry={entry} showStatus={entry.status !== 'posted'} />
{rattelseLog.length > 0 && (
<Badge variant="outline" title={t('rattelse_history_title')}>
@@ -576,6 +628,17 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{t('correct_opening_balances')}
</Button>
)}
{showStrikeButton && (
<Button
variant="outline"
onClick={() => setShowStrikeLines(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Scissors className="mr-2 h-4 w-4" />}
{t('strike_lines')}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -774,9 +837,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<span className="line-through decoration-muted-foreground/70">
{s.line_description || ''}
</span>
<span className="ml-2 text-xs">
{t('struck_marker', { date: formatDate(s.struck_at) })}
</span>
<span data-ph-mask="" className="ml-2 text-xs">{struckMarker(s)}</span>
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums whitespace-nowrap line-through decoration-muted-foreground/70')}>
{Number(s.debit_amount) > 0 && fmtAmount(s.debit_amount)}
@@ -852,7 +913,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{s.line_description && (
<p className="truncate text-xs line-through decoration-muted-foreground/70">{s.line_description}</p>
)}
<p className="text-xs">{t('struck_marker', { date: formatDate(s.struck_at) })}</p>
<p data-ph-mask="" className="text-xs">{struckMarker(s)}</p>
</div>
<div className="shrink-0 text-right tabular-nums line-through decoration-muted-foreground/70">
{Number(s.debit_amount) > 0 && <p>{fmtAmount(s.debit_amount)} D</p>}
@@ -963,7 +1024,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{rattelseLog.map((row) => (
<li key={row.id} className="py-2">
<div className="flex items-center justify-between gap-3">
<span className="tabular-nums text-muted-foreground">{formatDate(row.created_at)}</span>
{/* data-ph-mask: the actor label is a person's e-mail or name */}
<span data-ph-mask="" className="text-muted-foreground">
<span className="tabular-nums">{formatDate(row.created_at)}</span>
{row.actor_label ? ` · ${row.actor_label}` : ''}
</span>
<span className="text-xs text-muted-foreground">
{row.rattelse_type === 'metadata' ? t('rattelse_kind_metadata') : t('rattelse_kind_lines')}
</span>
@@ -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,
})),
})
},
)
+3
View File
@@ -5236,6 +5236,9 @@
"strike_lines": "Strike lines in the voucher",
"correct_metadata": "Change text or date",
"struck_marker": "Struck {date}",
"struck_marker_by": "Struck {date} by {actor}",
"help_rattelse_tracks_open": "While the period is open and unlocked, a posted voucher is corrected inside the voucher itself: strike lines or change the text and date. The original stays visible with a strikethrough and every correction is logged with who and when.",
"help_rattelse_tracks_locked": "Once the period is locked or closed, storno is the only path: the voucher is reversed and, if needed, replaced by a correction voucher. If the month has already been VAT-declared, correcting VAT accounts can affect the filed declaration.",
"rattelse_history_title": "Correction history",
"rattelse_kind_metadata": "Text/date corrected",
"rattelse_kind_lines": "Lines struck and replaced",
+3
View File
@@ -5236,6 +5236,9 @@
"strike_lines": "Stryk rader i verifikatet",
"correct_metadata": "Ändra text eller datum",
"struck_marker": "Struken {date}",
"struck_marker_by": "Struken {date} av {actor}",
"help_rattelse_tracks_open": "I en öppen, olåst period rättar du ett bokfört verifikat direkt i verifikatet: stryk rader eller ändra text och datum. Originalet förblir synligt överstruket och varje rättelse loggas med vem och när.",
"help_rattelse_tracks_locked": "När perioden är låst eller stängd är storno den enda vägen: verifikatet återförs och ersätts vid behov av ett ändringsverifikat. Om månaden redan är momsdeklarerad kan en rättelse av momskonton påverka den inlämnade deklarationen.",
"rattelse_history_title": "Rättelsehistorik",
"rattelse_kind_metadata": "Text/datum rättat",
"rattelse_kind_lines": "Rader strukna och ersatta",