* 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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ca93ef3fb6
commit
22f0647d6c
@@ -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,
|
||||
})),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user